Pular para o conteúdo

E-2 — Transparência Salarial e Folha

Parte das Colônias de Empresas — Fase 1


Recebe e processa a autodeclaração da folha salarial da empresa — cargos e salários, sem identificação pessoal. Aplica a razão máxima salarial parametrizada (referência inicial: 10x) via algoritmo de compressão logarítmica que achata os extremos preservando a estrutura proporcional entre cargos adjacentes. Calcula o que mudaria na folha sem aumentar o custo total.

O diagnóstico é público no nível de agregados: razão atual, razão parametrizada, valor redistribuído, percentual de colaboradores afetados. Salários individuais não são expostos. Cada diagnóstico publicado é um dado público — comparável com outras empresas do mesmo porte e setor, agregável para análises territoriais.

Não decide sobre a empresa, não aplica mudanças automaticamente. Produz o diagnóstico.

A E-2 tem natureza dual: BFF acoplado para o formulário de upload de folha (controllers REST) e consumidor puro de eventos para manter projeção local de empresas e processar diagnósticos assincronamente. A publicação do evento empresa.folha_submetida e o processamento do diagnóstico ocorrem no mesmo fluxo síncrono do controller no MVP — o handler de evento existe para cenários de replay e Fase 2.

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

Camada Responsabilidade Documento
Front-end App React: formulário de folha (campos dinâmicos cargo/salário ou upload CSV), painel de diagnóstico, visualização comparativa A definir — segue padrão de D-1a - Front-end.md
BFF + Consumidor Servidor NestJS: validação, persistência, algoritmo de redistribuição, publicação de eventos Este documento

Especificação completa em Apêndice B - Colônias.md, seção “Colônia E-2 — Transparência Salarial e Folha”.


A E-2 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Expõe controllers REST para o front-end de upload de folha, publica eventos no barramento via EventBusService (N-0a) e consome empresa.cadastrada para manter projeção local de empresas.

src/empresa/e-2-transparencia-salarial/
├── e2.module.ts # Module definition + OnModuleInit
├── controllers/
│ ├── folha.controller.ts # POST /empresas/:id/folha (JSON) e /folha/csv (multipart)
│ └── diagnostico.controller.ts # GET /empresas/:id/diagnostico, /diagnosticos e /diagnosticos/:id
├── services/
│ ├── folha.service.ts # Validação, persistência, algoritmo, publicação e varredura de órfãos
│ ├── projecao-empresas.service.ts # Consumidor de empresa.cadastrada + replay na inicialização
│ ├── algoritmo.service.ts # Compressão logarítmica com Decimal
│ └── csv-folha.service.ts # Parser CSV interno
├── dto/
│ ├── submeter-folha.dto.ts # Contrato POST /empresas/:id/folha (itens {cargo, salario, jornada?})
│ └── diagnostico-response.dto.ts # Shapes das respostas de diagnóstico
├── repositories/
│ ├── folha-submissao.repository.ts # Acesso a e2.folhas_submissoes
│ ├── diagnostico.repository.ts # Acesso a e2.diagnosticos
│ ├── empresa-projecao.repository.ts # Acesso a e2.empresas_projecao
│ └── consumer-offset.repository.ts # Acesso a e2.consumer_offset
├── guards/
│ └── auth.guard.ts # Valida JWT, extrai cidadao_id como representante
└── e2.constants.ts # RAZAO_MAXIMA, limites de tamanho e nomes de evento
@Module({
imports: [],
controllers: [
FolhaController,
DiagnosticoController,
],
providers: [
FolhaService,
AlgoritmoService,
CsvFolhaService,
ProjecaoEmpresasService,
FolhaSubmissaoRepository,
DiagnosticoRepository,
EmpresaProjecaoRepository,
ConsumerOffsetRepository,
],
exports: [],
})
export class E2Module implements OnModuleInit {
constructor(
private readonly projecaoEmpresas: ProjecaoEmpresasService,
private readonly folhaService: FolhaService,
) {}
async onModuleInit() {
await this.projecaoEmpresas.iniciar();
await this.folhaService.iniciar();
}
}
  • O módulo não é @Global(). A E-2 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 ProjecaoEmpresasService.iniciar() faz o seed do cursor, o replay de empresa.cadastrada perdido e o registro do handler. O FolhaService.iniciar() faz a varredura de órfãos (submissões sem empresa.folha_submetida publicado e diagnósticos sem empresa.diagnóstico_salarial_publicado).
  • O módulo não importa ThrottlerModule.forRoot() — isso já é feito pela D-1a. O rate limiting usa @Throttle do NestJS direto nas rotas.
  • O AlgoritmoService é um serviço puro, sem dependências externas. Recebe array de salários e parâmetro, retorna o resultado da redistribuição. Testável isoladamente com zero mocks.

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

  • FolhaService.submeter(empresaId, dto, representanteId): valida, persiste, calcula e publica os eventos da folha em JSON.
  • FolhaService.processarCsv(empresaId, arquivo, representanteId): parseia o CSV e delega ao mesmo fluxo interno.
  • FolhaService.iniciar(): varredura de órfãos no boot.
  • AlgoritmoService.redistribuir(salarios, parametro): resultado com novosSalarios, razaoAtual, razaoNova, custoTotal, redistribuicaoTotal, acimaTeto e abaixoMinimo.
  • CsvFolhaService.parse(conteudo): parser CSV interno.
  • ProjecaoEmpresasService.iniciar() e onEmpresaCadastrada(evento): consumidor da projeção.
  • O DiagnosticoController lê o repositório direto, sem service intermediário.
Método Rota Controller Descrição
POST /api/empresas/:id/folha FolhaController Submete folha salarial em JSON. Validação, persistência, algoritmo e publicação de eventos. Retorna o diagnóstico com o impacto cargo a cargo.
POST /api/empresas/:id/folha/csv FolhaController Submete folha salarial em CSV multipart (campo arquivo). Mesmo processamento da rota JSON.
GET /api/empresas/:id/diagnostico DiagnosticoController Diagnóstico mais recente da empresa, apenas agregados. O id é validado como UUID v4 (ParseUUIDPipe), com HTTP 400 para identificador malformado.
GET /api/empresas/:id/diagnosticos DiagnosticoController Histórico paginado de diagnósticos (page 1-based e limit padrão 20, máximo 50). O id é validado como UUID v4 (ParseUUIDPipe).
GET /api/diagnosticos/:id DiagnosticoController Diagnóstico específico por ID, apenas agregados. O id é validado como UUID v4 (ParseUUIDPipe).

A E-2 é uma colônia com BFF acoplado. Expõe endpoints REST diretamente para o front-end de upload de folha e visualização de diagnóstico. O processamento do algoritmo de redistribuição é síncrono no fluxo do controller — o resultado é retornado na resposta HTTP. O evento empresa.diagnóstico_salarial_publicado é publicado no mesmo fluxo.

A E-2 também consome eventos do barramento (empresa.cadastrada) como colônia pura para manter projeção local de empresas. Essa face da E-2 não expõe endpoints e opera exclusivamente via handler de evento. As duas faces coexistem no mesmo módulo, sem acoplamento interno: FolhaService não chama ProjecaoEmpresasService e vice-versa. A comunicação entre elas é indireta, via banco de dados próprio e barramento.

O processamento da folha ocorre no fluxo síncrono do controller. O único consumo de evento da colônia é empresa.cadastrada, para a projeção local. O reprocessamento da folha por handler de evento é Fase 2.


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

Projeção local de empresas, populada pelo consumidor de empresa.cadastrada. Contém apenas os campos relevantes para a E-2 validar submissões e exibir contexto no diagnóstico.

CREATE SCHEMA IF NOT EXISTS e2;
CREATE TABLE e2.empresas_projecao (
empresa_id UUID NOT NULL,
razao_social VARCHAR(300) NOT NULL,
porte VARCHAR(20) NOT NULL,
representante_id UUID,
status VARCHAR(30) NOT NULL DEFAULT 'cadastrada',
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT empresas_projecao_pkey PRIMARY KEY (empresa_id)
);
CREATE INDEX empresas_projecao_status_idx ON e2.empresas_projecao (status);
CREATE INDEX empresas_projecao_representante_id_idx ON e2.empresas_projecao (representante_id);

Os limites de status são validados em aplicação (STATUS_VALIDOS em e2.constants.ts), não no banco.

Coluna Tipo Descrição
empresa_id UUID PK Identificador da empresa. Extraído do payload de empresa.cadastrada.
razao_social VARCHAR(300) NOT NULL Nome oficial da organização. Usado para contexto no diagnóstico.
porte VARCHAR(20) NOT NULL micro, pequena, media ou grande.
representante_id UUID cidadão_id do representante que cadastrou a empresa. Extraído do payload de empresa.cadastrada e usado na autorização das submissões de folha.
status VARCHAR(30) NOT NULL cadastrada (default), socializada, pendente, recusada.
criado_em TIMESTAMPTZ(2) Timestamp de criação.
atualizado_em TIMESTAMPTZ(2) Timestamp da última atualização. Usado para detectar mudanças via replay de evento.

Registro bruto de cada submissão de folha salarial. Armazena o payload original (cargos) como JSONB para preservar o dado bruto. O diagnóstico é armazenado em tabela separada.

CREATE TABLE e2.folhas_submissoes (
id UUID NOT NULL,
empresa_id UUID NOT NULL,
total_colaboradores INTEGER NOT NULL,
cargos JSONB NOT NULL,
formato_entrada VARCHAR(10) NOT NULL,
idempotencia_hash VARCHAR(64) NOT NULL,
evento_publicado_em TIMESTAMPTZ(2),
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT folhas_submissoes_pkey PRIMARY KEY (id)
);
CREATE UNIQUE INDEX folhas_submissoes_idempotencia_hash_key
ON e2.folhas_submissoes (idempotencia_hash);
CREATE INDEX folhas_submissoes_empresa_id_idx
ON e2.folhas_submissoes (empresa_id);
Coluna Tipo Descrição
id UUID PK submissao_id. Gerado pela aplicação.
empresa_id UUID NOT NULL Empresa que submeteu a folha. Validado contra e2.empresas_projecao.
total_colaboradores INTEGER NOT NULL Quantidade de cargos na submissão, sempre >= 1 pela validação de aplicação.
cargos JSONB NOT NULL Array bruto: [{cargo, salario, jornada?}, ...]. Dado original preservado.
formato_entrada VARCHAR(10) NOT NULL form (JSON do formulário) ou csv (convertido de CSV).
idempotencia_hash VARCHAR(64) UNIQUE Hash SHA-256 do conteúdo determinístico. Garante idempotência.
evento_publicado_em TIMESTAMPTZ(2) Preenchido após publicação bem-sucedida de empresa.folha_submetida.
criado_em TIMESTAMPTZ(2) Timestamp de criação.

Resultado do processamento do algoritmo de redistribuição. Contém o breakdown completo do diagnóstico e o payload exato que foi publicado em empresa.diagnóstico_salarial_publicado.

CREATE TABLE e2.diagnosticos (
id UUID NOT NULL,
submissao_id UUID NOT NULL,
empresa_id UUID NOT NULL,
menor_salario DECIMAL(15,2) NOT NULL,
maior_salario DECIMAL(15,2) NOT NULL,
razao_atual DECIMAL(15,4) NOT NULL,
razao_parametrizada DECIMAL(10,2) NOT NULL,
custo_total_antes DECIMAL(15,2) NOT NULL,
custo_total_depois DECIMAL(15,2) NOT NULL,
redistribuicao_total DECIMAL(15,2) NOT NULL,
colaboradores_acima_teto INTEGER NOT NULL DEFAULT 0,
colaboradores_abaixo_minimo INTEGER NOT NULL DEFAULT 0,
versao_parametros VARCHAR(20) NOT NULL,
cargos_processados JSONB NOT NULL,
payload_diagnostico JSONB NOT NULL,
evento_publicado_em TIMESTAMPTZ(2),
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT diagnosticos_pkey PRIMARY KEY (id),
CONSTRAINT diagnosticos_submissao_id_fkey
FOREIGN KEY (submissao_id) REFERENCES e2.folhas_submissoes(id)
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX diagnosticos_submissao_id_key
ON e2.diagnosticos (submissao_id);
CREATE INDEX diagnosticos_empresa_id_idx
ON e2.diagnosticos (empresa_id);
CREATE INDEX diagnosticos_empresa_id_criado_em_idx
ON e2.diagnosticos (empresa_id, criado_em DESC);
Coluna Tipo Descrição
id UUID PK diagnostico_id. Gerado pela aplicação.
submissao_id UUID NOT NULL FK UNIQUE FK para e2.folhas_submissoes(id) com CASCADE. Um diagnóstico por submissão.
empresa_id UUID NOT NULL Empresa do diagnóstico. Denormalizado para queries sem JOIN.
menor_salario DECIMAL(15,2) NOT NULL Menor salário declarado, antes da redistribuição.
maior_salario DECIMAL(15,2) NOT NULL Maior salário declarado, antes da redistribuição.
razao_atual DECIMAL(15,4) NOT NULL Razão maior/menor antes da redistribuição. 4 casas decimais para precisão em valores altos.
razao_parametrizada DECIMAL(10,2) NOT NULL Razão máxima do parâmetro vigente usado no cálculo.
custo_total_antes DECIMAL(15,2) NOT NULL Soma de todos os salários antes da redistribuição.
custo_total_depois DECIMAL(15,2) NOT NULL Soma após redistribuição. Igual a custo_total_antes por construção, com diferença máxima de centavos por arredondamento.
redistribuicao_total DECIMAL(15,2) NOT NULL Volume total de salário redistribuído (soma das reduções, igual à soma dos aumentos).
colaboradores_acima_teto INTEGER NOT NULL Colaboradores cujo salário foi reduzido (salário_depois < salário_antes).
colaboradores_abaixo_minimo INTEGER NOT NULL Colaboradores cujo salário foi aumentado (salário_depois > salário_antes).
versao_parametros VARCHAR(20) NOT NULL Versão dos parâmetros usados. No MVP: string fixa "mvp-v1". Na Fase 2: versão do evento parâmetros.atualizados.
cargos_processados JSONB NOT NULL Array de {cargo, salario_antes, salario_depois, variacao_percentual}. Dado estruturado para o front-end de diagnóstico.
payload_diagnostico JSONB NOT NULL Payload exato publicado no evento empresa.diagnóstico_salarial_publicado. Para auditoria e replay.
evento_publicado_em TIMESTAMPTZ(2) Preenchido após publicação bem-sucedida do evento.
criado_em TIMESTAMPTZ(2) Timestamp de criação.

O campo cargos_processados contém o nome do cargo e os valores de antes/depois — permitindo que o front-end do painel da empresa exiba o impacto cargo a cargo. O payload público do evento empresa.diagnóstico_salarial_publicado (armazenado em payload_diagnostico) não inclui os cargos processados — apenas agregados. Os cargos individuais são acessíveis apenas ao representante da empresa via endpoint autenticado.

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 e2.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: empresa.cadastrada. O seed roda no boot com obterMaiorSequence(). A estrutura com PK em tipo_evento permite extensão futura sem alteração de schema (ex: parâmetros.atualizados na Fase 2).

  1. 20260813191200_create_e2_tables — Cria o schema e2, as tabelas empresas_projecao, folhas_submissoes, diagnosticos e consumer_offset, com índices, uniques e a FK interna do diagnóstico.
  2. 20260818090000_add_representante_id_empresas_projecao — Adiciona representante_id em e2.empresas_projecao com índice.
  3. 20260818120000_backfill_representante_id_empresas_projecao — Preenche o representante_id das projeções existentes a partir dos eventos.

O schema e2 tem uma foreign key interna:

  • diagnosticos.submissao_id → folhas_submissoes.id (CASCADE)

Esta é a única FK permitida. FKs entre schemas de colônias distintas são proibidas pela regra de isolamento. A projeção e2.empresas_projecao não tem FK para o schema e1 — a integridade é garantida em aplicação via consumo do evento empresa.cadastrada.

cargos como JSONB, não como tabela relacional. Os cargos são recebidos como array, processados como um único batch atômico e exibidos inline no diagnóstico. Uma tabela e2.cargos_submissao com FK traria complexidade de JOIN sem ganho — a E-2 nunca consulta cargos individuais isoladamente. O JSONB preserva a ordem de entrada, aceita schema flexível (jornada opcional) e é indexável caso surja necessidade futura de busca textual por nome de cargo.

cargos_processados como JSONB em e2.diagnosticos, não como tabela separada. Mesmo raciocínio. O array processado é acessado sempre junto com o diagnóstico. A separação em tabela adicionaria N INSERTs por diagnóstico sem benefício de query.

payload_diagnostico como coluna separada em e2.diagnosticos. O payload exato publicado no evento deve ser preservado para auditoria. Se o schema do evento evoluir (versão 1.1.0), diagnósticos antigos mantêm o payload original. A coluna payload_diagnostico é a fonte para replay e verificação de consistência entre o que foi calculado e o que foi publicado.

DECIMAL(15,2) para valores monetários. Precisão de centavos para valores de até 10 trilhões — suficiente para qualquer folha salarial. O tipo NUMERIC do PostgreSQL garante aritmética decimal exata, sem os problemas de ponto flutuante do IEEE 754. A aplicação também usa Decimal (via decimal.js ou nativo BigInt em cents) para o algoritmo de redistribuição, garantindo que custo_total_antes == custo_total_depois ao centavo.

colaboradores_acima_teto e colaboradores_abaixo_minimo definidos pelo resultado do algoritmo. No algoritmo original do Apêndice B, “acima do teto” e “abaixo do mínimo” referiam-se a thresholds fixos (teto = menor × parâmetro, mínimo = teto). Com o algoritmo de compressão logarítmica, não há thresholds fixos — o salário de cada colaborador se move para cima ou para baixo dependendo da posição relativa na distribuição. Os campos são preenchidos com a contagem de colaboradores que tiveram redução (acima_teto) e aumento (abaixo_minimo). A semântica é: quantos cederam e quantos receberam.

Projeção e2.empresas_projecao com campos mínimos. A E-2 precisa de empresa_id para validação, razao_social e porte para contexto no diagnóstico, status para filtrar empresas inativas e representante_id para autorizar a submissão. Campos como cnpj, site e setor_id não são usados pela E-2. Na Fase 2, se o diagnóstico precisar de segmentação por setor, o campo setor_id é adicionado à projeção sem quebrar dados existentes.


A E-2 produz dois tipos de evento e consome dois. 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-2.

Propriedade Valor
Tipo empresa.folha_submetida
Schema version 1.0.0
Produtor E-2 (BFF — face de controller)
Consumidores E-3 (Simulação Econômica — total_colaboradores para o valor por trabalhador)
Descrição Folha salarial anonimizada submetida por uma empresa. Publicado imediatamente após persistência da submissão bruta, sem os cargos e salários no payload.

Payload publicado:

interface EmpresaFolhaSubmetidaPayload {
empresa_id: string; // UUID v4
submissao_id: string; // UUID v4
total_colaboradores: number; // >= 1
quantidade_cargos: number; // >= 1
cargos_hash: string; // SHA-256 da lista de cargos
}

O payload não carrega os cargos nem os salários. O cargos_hash sustenta a idempotência da submissão sem expor a folha, e o total_colaboradores alimenta o cálculo de valor por trabalhador na E-3.

3.2 Evento produzido: empresa.diagnóstico_salarial_publicado

Seção intitulada “3.2 Evento produzido: empresa.diagnóstico_salarial_publicado”
Propriedade Valor
Tipo empresa.diagnóstico_salarial_publicado
Schema version 1.0.0
Produtor E-2 (esta colônia)
Consumidores E-3 (Simulação Econômica — custo_total_depois), D-7 (Transparência)
Descrição Diagnóstico salarial: razão atual, redistribuição simulada, impacto agregado na folha. Nenhum salário individual exposto.

Payload publicado:

interface EmpresaDiagnosticoSalarialPublicadoPayload {
empresa_id: string; // UUID v4
submissao_id: string; // UUID v4 — correlaciona com a submissão
menor_salario: number; // > 0, precisão de centavos
maior_salario: number; // > 0
razao_atual: number; // > 0, 4 casas decimais
razao_parametrizada: number; // > 0, 2 casas decimais (ex: 10.00)
custo_total_antes: number; // >= 0
custo_total_depois: number; // >= 0, ≈ custo_total_antes
redistribuicao_total: number; // >= 0
colaboradores_acima_teto: number; // >= 0, integer — quantos cederam
colaboradores_abaixo_minimo: number; // >= 0, integer — quantos receberam
versao_parametros: string; // semver (ex: "mvp-v1" no MVP)
}
Propriedade Valor
Tipo empresa.cadastrada
Schema version 1.0.0
Produtor E-1 (Cadastro Institucional)
Consumidor E-2 (esta colônia — projeção local)
Descrição Nova empresa registrada. A E-2 insere ou atualiza e2.empresas_projecao.

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

interface EmpresaCadastradaPayload {
empresa_id: string;
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;
termos_aceitos_em: string; // ISO-8601
status: string; // 'cadastrada' | 'socializada' | 'pendente' | 'recusada'
cnpj?: string;
}

A E-2 extrai empresa_id, razao_social, porte, status e representante_id. Os demais campos são ignorados.

3.4 Ordem de operações — submeter folha (JSON)

Seção intitulada “3.4 Ordem de operações — submeter folha (JSON)”

O fluxo em FolhaService.submeter() segue esta ordem exata:

1. Validar que a empresa existe
→ consultar e2.empresas_projecao WHERE empresa_id = :id
→ se não encontrada: HTTP 404 "Empresa não encontrada"
→ se status NÃO está em ('cadastrada', 'socializada'): HTTP 409 "Empresa não está ativa para submissão de folha"
2. Verificar que o representante é o dono da empresa
→ AuthGuard extrai cidadao_id do JWT
→ comparar com empresas_projecao.representante_id
→ se diferente: HTTP 403 "Empresa não pertence ao representante autenticado"
3. Validar DTO de entrada
→ class-validator + class-transformer no pipe global do NestJS
→ campos mínimos: cargos (array não vazio), cada item: cargo (string 1-200 chars), salario (number > 0)
→ se inválido: HTTP 400, sem efeito colateral
4. Validar limites
→ cargos.length <= 1000 (E2_MAX_CARGOS)
→ se excedido: HTTP 400 "Máximo de 1000 cargos por submissão"
→ cada salario > 0 e <= 999999999999.99 (E2_MAX_SALARIO), o limite de DECIMAL(15,2)
→ se inválido: HTTP 400 "Salário inválido para o cargo X"
5. Verificar idempotência
→ gerar idempotencia_hash = SHA-256(
empresa_id + "|" +
representante_id + "|" +
JSON.stringify(cargos ordenados por cargo)
)
→ consultar e2.folhas_submissoes WHERE idempotencia_hash = ?
→ se encontrado: HTTP 409 { submissao_id: existente.id, duplicata: true }
6. Gerar submissao_id
→ submissao_id = UUID v4
7. PERSISTIR submissão bruta
→ INSERT INTO e2.folhas_submissoes (id, empresa_id, total_colaboradores, cargos, formato_entrada, idempotencia_hash)
→ se violação de UNIQUE(idempotencia_hash) por race condition: capturar, buscar existente, HTTP 409
8. PUBLICAR empresa.folha_submetida
→ publicarComRetry(this.eventBus, {
tipo: 'empresa.folha_submetida',
origem: 'E-2',
event_id: submissao_id,
correlacao_id: submissao_id,
payload: { empresa_id, submissao_id, total_colaboradores: dto.cargos.length,
quantidade_cargos: dto.cargos.length, cargos_hash }
})
→ se a publicação falhar: log.error, HTTP 500
→ atualizar e2.folhas_submissoes SET evento_publicado_em = NOW()
9. Executar algoritmo de redistribuição
→ extrair array de salarios = cargos.map(c => c.salario)
→ resultado = algoritmoService.redistribuir(salarios, parametro)
→ mapear resultado.novosSalarios de volta aos cargos originais (preservando a ordem)
→ gerar cargos_processados: array de {cargo, salario_antes, salario_depois, variacao_percentual}
→ variacao_percentual = ((salario_depois / salario_antes) - 1) * 100, arredondado para 2 casas decimais
10. Gerar diagnostico_id
→ diagnostico_id = UUID v4
11. Construir payload do diagnóstico
→ payload_diagnostico = {
empresa_id, submissao_id,
menor_salario, maior_salario: valores ANTES da redistribuição,
razao_atual, razao_parametrizada,
custo_total_antes, custo_total_depois,
redistribuicao_total,
colaboradores_acima_teto, colaboradores_abaixo_minimo,
versao_parametros
}
12. PERSISTIR diagnóstico
→ INSERT INTO e2.diagnosticos (id, submissao_id, empresa_id, ..., cargos_processados, payload_diagnostico)
13. PUBLICAR empresa.diagnóstico_salarial_publicado
→ publicarComRetry(this.eventBus, {
tipo: 'empresa.diagnóstico_salarial_publicado',
origem: 'E-2',
event_id: diagnostico_id,
correlacao_id: submissao_id,
payload: payload_diagnostico
})
→ se a publicação falhar: log.error, HTTP 500
→ atualizar e2.diagnosticos SET evento_publicado_em = NOW()
14. Retornar 201 { submissao_id, diagnostico_id, ...payload_diagnostico, cargos_processados }

A rota POST /empresas/:id/folha/csv recebe multipart/form-data com o campo arquivo e delega ao FolhaService.processarCsv(), que parseia e segue o mesmo fluxo interno da rota JSON:

1. Validar arquivo
→ campo multipart `arquivo` obrigatório
→ tamanho máximo do arquivo: 1 MB (E2_MAX_CSV_BYTES)
→ se excedido: HTTP 413 "Arquivo muito grande"
2. Parse CSV
→ parser CSV interno (`csv-folha.service.ts`): vírgula ou ponto-e-vírgula, aspas, BOM, R$ e milhar; sem papaparse/csv-parse
→ encoding: UTF-8 (com detecção de BOM)
→ delimitador: vírgula (,) com fallback para ponto-e-vírgula (;) se nenhuma vírgula encontrada
→ cabeçalho esperado: cargo,salario[,jornada]
→ se cabeçalho inválido: HTTP 400 "CSV deve ter colunas: cargo,salario[,jornada]"
→ para cada linha:
→ cargo: string, trim, 1-200 caracteres
→ salario: número, > 0, aceita formatos 1000, 1.000, 1000.00, 1.000,00
→ jornada: opcional, 'integral' ou 'meio_periodo' (case-insensitive)
→ se linha inválida: HTTP 400 "Linha X: erro — descrição do erro"
3. Prosseguir com o mesmo fluxo da rota JSON a partir do passo 1, com formato_entrada = 'csv'

3.6 Ordem de operações — consumir empresa.cadastrada

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

O fluxo em ProjecaoEmpresasService.onEmpresaCadastrada() segue esta ordem:

1. Extrair dados do payload
→ empresa_id = payload.empresa_id
→ razao_social = payload.razao_social
→ porte = payload.porte
→ status = payload.status ?? 'cadastrada'
→ representante_id = payload.representante_id
2. Validar campos e status
→ empresa_id, razao_social e porte obrigatórios
→ se ausente: logger.error, avançar cursor, retornar
→ status fora de STATUS_VALIDOS: logger.error, avançar cursor, retornar
3. PERSISTIR ou ATUALIZAR projeção
→ UPSERT em e2.empresas_projecao:
INSERT ... ON CONFLICT (empresa_id) DO UPDATE SET
razao_social = EXCLUDED.razao_social,
porte = EXCLUDED.porte,
representante_id = EXCLUDED.representante_id,
status = EXCLUDED.status,
atualizado_em = NOW()
4. Avançar o cursor de e2.consumer_offset para o sequence_number do evento

A E-2 implementa três camadas de idempotência:

Camada 1 — Aplicação (E-2): O idempotencia_hash impede INSERTs duplicados em e2.folhas_submissoes. O hash é SHA-256 de empresa_id + representante_id + cargos ordenados por nome, sem componente temporal. A ordenação garante que duas submissões com os mesmos cargos em ordem diferente gerem o mesmo hash, e a idempotência é permanente: retries de rede retornam 409 até que o conteúdo mude.

Camada 2 — Diagnóstico: A unique diagnosticos_submissao_id_key em submissao_id impede que uma submissão gere dois diagnósticos. O reprocessamento assíncrono por handler de evento é Fase 2.

Camada 3 — Barramento (N-0a): O EventBusService.publicar() usa event_id como chave de idempotência. Para empresa.folha_submetida, o event_id é o próprio submissao_id. Para empresa.diagnóstico_salarial_publicado, o event_id é o diagnostico_id.

Cenário Comportamento
Empresa não encontrada na projeção HTTP 404. Nenhum efeito colateral.
Empresa com status inativo HTTP 409. “Empresa não está ativa para submissão de folha”.
Cargos vazios HTTP 400. “Ao menos um cargo é obrigatório”.
Salário <= 0 ou não numérico HTTP 400. “Salário inválido para o cargo X”.
Cidadão autenticado que não é o representante da empresa HTTP 403. “Empresa não pertence ao representante autenticado”.
CSV com formato inválido HTTP 400 com mensagem do parser.
Arquivo CSV > 1 MB HTTP 413. “Arquivo muito grande”.
Campo arquivo ausente na rota CSV HTTP 400. “Arquivo CSV não fornecido (campo multipart arquivo)”.
Mais de 1000 cargos HTTP 400. “Máximo de 1000 cargos por submissão”.
Rate limit excedido HTTP 429.
Duplicata detectada (idempotencia_hash) HTTP 409. Body: { submissao_id, duplicata: true }. Nenhum evento publicado.
INSERT folha_submissao falha HTTP 500. Nenhum evento publicado.
Publicação de empresa.folha_submetida falha após as tentativas Erro logado. HTTP 500. Registro existe com evento_publicado_em = NULL e a varredura de órfãos do boot republica.
Algoritmo lança exceção (ex: overflow) Erro logado com dados da submissão. HTTP 500. Submissão bruta está persistida e pode ser reprocessada.
INSERT diagnóstico falha Erro de infraestrutura. HTTP 500. Submissão bruta existe. empresa.folha_submetida já publicado.
Publicação de empresa.diagnóstico_salarial_publicado falha após as tentativas Erro logado. HTTP 500. Diagnóstico existe em e2.diagnosticos com evento_publicado_em = NULL e a varredura de órfãos do boot republica.
empresa.cadastrada chega com empresa já existente na projeção UPSERT: atualiza campos. Idempotente.
empresa.cadastrada chega com status = 'recusada' UPSERT normalmente. Submissões futuras para esta empresa serão bloqueadas pelo passo 1 do fluxo.
empresa.cadastrada chega com payload incompleto ou status inválido Log.error, cursor avançado, evento descartado.

Persistir submissão bruta antes de publicar eventos. Mesmo princípio da E-1 e D-1a: 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.

Processamento síncrono no controller, não no handler de evento. O algoritmo de compressão logarítmica é O(n log n) e processa 1000 cargos em < 10ms em JavaScript. Assincronia adicionaria latência perceptível ao usuário (polling) sem ganho de throughput. Na Fase 2, se houver necessidade de processamento desacoplado (ex: validação cruzada com base de mercado), a lógica migra do controller para um handler de evento sem alterar o contrato de eventos.

Projeção local de empresas via empresa.cadastrada, não via query à E-1. Consultar a E-1 via HTTP violaria o isolamento de schemas e introduziria dependência síncrona entre colônias. Manter uma projeção local atualizada por eventos mantém a E-2 autossuficiente e resiliente a falhas da E-1. O custo é uma tabela adicional com < 10 KB por empresa.

Validação de propriedade pelo representante_id da projeção. O evento empresa.cadastrada carrega representante_id, e a projeção guarda o campo. A E-2 compara o cidadao_id do JWT com o representante da empresa e responde 403 quando não coincide. A validação protege a submissão de folha e o balanço da E-3 com a mesma regra.

CSV e JSON em rotas separadas. O JSON entra por POST /empresas/:id/folha e o CSV por POST /empresas/:id/folha/csv (multipart, campo arquivo). As duas rotas convergem para o mesmo DTO e fluxo de processamento. O FolhaService recebe sempre um SubmeterFolhaDto padronizado; a duplicação de entrada fica no controller.

jornada armazenada mas não usada no cálculo. O campo jornada (integral/meio_periodo) é metadado informativo. A normalização (ex: multiplicar meio período por 2 para comparar como integral) introduz complexidade de interpretação — um salário de meio período pode refletir senioridade, não apenas proporção de horas. O valor bruto declarado é usado no cálculo. O campo fica visível no diagnóstico para transparência. A normalização pode ser adicionada na Fase 2 como opção parametrizável.


4.1 AlgoritmoService.redistribuir() — compressão logarítmica

Seção intitulada “4.1 AlgoritmoService.redistribuir() — compressão logarítmica”

O algoritmo aplica compressão logarítmica uniforme para achatar a distribuição salarial preservando a estrutura proporcional entre cargos adjacentes. A intuição: se dois cargos tinham uma diferença de 20% entre si, após a compressão a diferença será de 20%^alpha (onde alpha < 1 é o fator de compressão), mantendo a hierarquia relativa intacta.

função redistribuir(salarios: number[], parametro: number) -> RedistribuicaoResultado:
entrada:
salarios: array de salários brutos (já validados: > 0)
parametro: razão máxima permitida (ex: 10.0)
// 1. Edge cases — sem redistribuição necessária
n = salarios.length
se n <= 1:
retornar {
novosSalarios: [...salarios],
razaoAtual: 1.0,
razaoNova: 1.0,
custoTotal: salarios[0] ?? 0,
redistribuicaoTotal: 0,
acimaTeto: 0,
abaixoMinimo: 0,
}
// 2. Ordenar salarios (crescente), preservando índices originais
indices = array de 0..n-1
ordenar indices por salarios[i] crescente (desempate: índice original)
salariosOrdenados = indices.map(i => salarios[i])
menor = salariosOrdenados[0]
maior = salariosOrdenados[n - 1]
razaoAtual = maior / menor
// 3. Se já está dentro do parâmetro, não há o que redistribuir
se razaoAtual <= parametro:
retornar {
novosSalarios: [...salarios],
razaoAtual,
razaoNova: razaoAtual,
custoTotal: soma(salarios),
redistribuicaoTotal: 0,
acimaTeto: 0,
abaixoMinimo: 0,
}
// 4. Compressão logarítmica
// Transforma para espaço log, comprime a faixa, volta para espaço linear
logs = salariosOrdenados.map(s => ln(s))
rangeLog = logs[n - 1] - logs[0]
alpha = ln(parametro) / rangeLog
// alpha está no intervalo (0, 1) porque razaoAtual > parametro
logsComprimidos = logs.map((l, i) => logs[0] + (l - logs[0]) * alpha)
temporarios = logsComprimidos.map(l => exp(l))
// 5. Rescaling para manter custo total constante
custoTotal = soma(salariosOrdenados)
somaTemp = soma(temporarios)
fatorEscala = custoTotal / somaTemp
novosSalariosOrdenados = temporarios.map(t => t * fatorEscala)
// 6. Verificação da invariante
// novosSalariosOrdenados[n-1] / novosSalariosOrdenados[0] == parametro
// (a verificação é matemática, não computacional — arredondamentos podem causar
// diferença na última casa decimal; tolerância de 1e-10)
// 7. Desordenar — mapear de volta à ordem original
// Criamos um array de pares (indice_original, novo_salario) a partir da ordenação
novosSalarios = new Array(n)
para cada posicao k em 0..n-1:
indiceOriginal = indices[k]
novosSalarios[indiceOriginal] = arredondar(novosSalariosOrdenados[k], 2)
// 8. Calcular métricas do diagnóstico
// Para cada colaborador, comparar salario original com novo
acimaTeto = 0
abaixoMinimo = 0
redistribuicaoTotal = 0
para cada i em 0..n-1:
diff = novosSalarios[i] - salarios[i]
se diff < -0.005: // tolerância de arredondamento
acimaTeto++
redistribuicaoTotal += abs(diff)
senão se diff > 0.005:
abaixoMinimo++
// redistribuicaoTotal já foi calculado como soma das reduções
// (matematicamente igual à soma dos aumentos)
retornar {
novosSalarios,
razaoAtual,
razaoNova: parametro,
custoTotal,
redistribuicaoTotal: arredondar(redistribuicaoTotal, 2),
acimaTeto,
abaixoMinimo,
}

Entrada: salarios = [1000, 1500, 3000, 10000, 100000], parametro = 10

Etapa Valores
Ordenados [1000, 1500, 3000, 10000, 100000]
menor, maior 1000, 100000
razão atual 100.0 (> 10 → comprime)
ln(salarios) [6.908, 7.313, 8.006, 9.210, 11.513]
rangeLog 11.513 - 6.908 = 4.605
alpha ln(10) / 4.605 = 2.3026 / 4.605 = 0.5000
ln comprimidos [6.908, 7.110, 7.457, 8.059, 9.210]
temporarios [1000.0, 1224.7, 1732.1, 3159.8, 10000.0]
somaTemp 17116.6
fatorEscala (1000+1500+3000+10000+100000) / 17116.6 = 115500 / 17116.6 = 6.748
Novos salários [6748, 8264, 11688, 21328, 67479]
Nova razão 67479 / 6748 ≈ 10.00
Custo total 6748+8264+11688+21328+67479 = 115507 ≈ 115500 (diferença de arredondamento)
Acima do teto (redução) 2 colaboradores (10k→21.3k? não — 10.000 original vs 21.328 novo? Wait, isso é aumento…)

Correção da análise: No exemplo, o colaborador de 10.000 passa a receber 21.328 — um aumento. O colaborador de 100.000 passa a receber 67.479 — uma redução. Portanto:

Original Novo Variação Status
1.000 6.748 +574.8% Aumento (abaixo_minimo)
1.500 8.264 +450.9% Aumento (abaixo_minimo)
3.000 11.688 +289.6% Aumento (abaixo_minimo)
10.000 21.328 +113.3% Aumento (abaixo_minimo)
100.000 67.479 -32.5% Redução (acima_teto)

Resultado: acima_teto = 1, abaixo_minimo = 4, redistribuicao_total = 100000 - 67479 = 32521.

A compressão logarítmica preserva a estrutura proporcional: os gaps originais (1.5x, 2.0x, 3.33x, 10x) são comprimidos para (1.5^0.5 = 1.225x, 2.0^0.5 = 1.414x, 3.33^0.5 = 1.826x, 10^0.5 = 3.162x). A hierarquia entre cargos é mantida; apenas a amplitude é reduzida.

função submeter(empresaId: string, dto: SubmeterFolhaDto, representanteId: string) -> DiagnosticoResponse:
// 1. Validar empresa e representante
empresa = empresaProjecaoRepo.buscarPorId(empresaId)
se empresa é null:
lançar ErroEmpresaNaoEncontrada()
se empresa.status não está em STATUS_EMPRESAS_ATIVAS:
lançar ErroEmpresaInativa()
se empresa.representante_id != representanteId:
lançar ErroEmpresaNaoAutorizada()
// 2. Validação de campos (delegada ao ValidationPipe do NestJS + class-validator)
// 3. Validar limites
se dto.cargos.length > E2_MAX_CARGOS:
lançar ErroMaximoCargos()
para cada cargo em dto.cargos:
se cargo.salario <= 0 ou cargo.salario > E2_MAX_SALARIO:
lançar ErroSalarioInvalido(cargo.cargo)
// 4. Idempotência
cargosOrdenados = dto.cargos ordenado por cargo asc
hashInput = empresaId + "|" +
representanteId + "|" +
JSON.stringify(cargosOrdenados)
idempotenciaHash = SHA256(hashInput)
existente = folhaRepo.buscarPorIdempotenciaHash(idempotenciaHash)
se existente não é null:
lançar ErroFolhaDuplicada(existente.id)
// 5. Gerar IDs
submissaoId = UUIDv4()
diagnosticoId = UUIDv4()
// 6. Persistir submissão bruta
folhaRepo.inserir({
id: submissaoId,
empresa_id: empresaId,
total_colaboradores: dto.cargos.length,
cargos: dto.cargos,
formato_entrada: 'form',
idempotencia_hash: idempotenciaHash,
})
// violação de unique na corrida: capturar, buscar o existente e lançar ErroFolhaDuplicada
// 7. Publicar empresa.folha_submetida
tentar:
await publicarComRetry(this.eventBus, {
tipo: 'empresa.folha_submetida',
origem: 'E-2',
event_id: submissaoId,
correlacao_id: submissaoId,
payload: {
empresa_id: empresaId,
submissao_id: submissaoId,
total_colaboradores: dto.cargos.length,
quantidade_cargos: dto.cargos.length,
cargos_hash: SHA256(JSON.stringify(dto.cargos)),
},
})
folhaRepo.atualizarEventoPublicadoEm(submissaoId, agora)
capturar erro:
logger.error("Falha ao publicar empresa.folha_submetida", erro, {
empresa_id: empresaId,
submissao_id: submissaoId,
})
lançar ErroPublicacaoEvento()
// 9. Executar algoritmo
salarios = dto.cargos.map(c => c.salario)
resultado = algoritmoService.redistribuir(salarios, RAZAO_MAXIMA)
// 10. Mapear resultados de volta aos cargos
cargosProcessados = []
para i de 0 até dto.cargos.length - 1:
cargo = dto.cargos[i]
novoSalario = resultado.novosSalarios[i]
variacaoPct = ((novoSalario / cargo.salario) - 1) * 100
cargosProcessados.push({
cargo: cargo.cargo,
salario_antes: cargo.salario,
salario_depois: arredondar(novoSalario, 2),
variacao_percentual: arredondar(variacaoPct, 2),
})
// 11. Construir payload do diagnóstico
menorOriginal = min(salarios)
maiorOriginal = max(salarios)
payloadDiagnostico = {
empresa_id: empresaId,
submissao_id: submissaoId,
menor_salario: menorOriginal,
maior_salario: maiorOriginal,
razao_atual: arredondar(resultado.razaoAtual, 4),
razao_parametrizada: RAZAO_MAXIMA,
custo_total_antes: arredondar(resultado.custoTotal, 2),
custo_total_depois: arredondar(soma(resultado.novosSalarios), 2),
redistribuicao_total: resultado.redistribuicaoTotal,
colaboradores_acima_teto: resultado.acimaTeto,
colaboradores_abaixo_minimo: resultado.abaixoMinimo,
versao_parametros: VERSAO_PARAMETROS,
}
// 12. Persistir diagnóstico
diagnosticoRepo.inserir({
id: diagnosticoId,
submissao_id: submissaoId,
empresa_id: empresaId,
...campos do payloadDiagnostico,
cargos_processados: cargosProcessados,
payload_diagnostico: payloadDiagnostico,
})
// 13. Publicar empresa.diagnóstico_salarial_publicado
tentar:
await publicarComRetry(this.eventBus, {
tipo: 'empresa.diagnóstico_salarial_publicado',
origem: 'E-2',
event_id: diagnosticoId,
correlacao_id: submissaoId,
payload: payloadDiagnostico,
})
diagnosticoRepo.atualizarEventoPublicadoEm(diagnosticoId, agora)
capturar erro:
logger.error("Falha ao publicar empresa.diagnóstico_salarial_publicado", erro, {
empresa_id: empresaId,
submissao_id: submissaoId,
diagnostico_id: diagnosticoId,
})
lançar ErroPublicacaoEvento()
// 14. Retornar
retornar {
submissao_id: submissaoId,
diagnostico_id: diagnosticoId,
...payloadDiagnostico,
cargos_processados: cargosProcessados,
}
função processarCsv(empresaId: string, arquivo: Buffer, representanteId: string) -> DiagnosticoResponse:
// 1. Validar tamanho
se arquivo.length > E2_MAX_CSV_BYTES:
lançar ErroArquivoMuitoGrande()
// 2. Parse CSV com o parser interno
cargos = csvService.parse(arquivo)
// O parser cobre BOM, delimitador (vírgula ou ponto-e-vírgula), aspas,
// R$ e milhar, cabeçalho cargo,salario[,jornada] e valida cada linha.
// Erros viram ErroCsvInvalido com a linha e a descrição.
// 3. Delegar ao fluxo interno com formato_entrada = 'csv'
dto = { cargos }
retornar await this.executar(empresaId, dto, representanteId, 'csv')
função onEmpresaCadastrada(evento: EventoConsultado):
payload = evento.payload
sequencia = BigInt(evento.sequence_number)
empresaId = payload.empresa_id
razaoSocial = payload.razao_social
porte = payload.porte
status = payload.status ?? 'cadastrada'
se !empresaId ou !razaoSocial ou !porte:
logger.error("Payload de empresa.cadastrada incompleto — descartando")
avançarCursor(sequencia, evento)
retornar
se status fora de STATUS_VALIDOS:
logger.error("Status de empresa inválido — descartando")
avançarCursor(sequencia, evento)
retornar
empresaProjecaoRepo.upsert({
empresa_id: empresaId,
razao_social: razaoSocial,
porte,
representante_id: payload.representante_id,
status,
})
avançarCursor(sequencia, evento)
função iniciar():
// 1. Garantir o cursor no boot; o seed não sobrescreve cursor existente
maiorSequence = await eventBus.obterMaiorSequence()
consumerOffsetRepo.seed(['empresa.cadastrada'], maiorSequence)
// 2. Replay de eventos perdidos a partir do cursor
offsets = consumerOffsetRepo.buscarTodos()
cursor = offsets.find(o => o.tipo_evento == 'empresa.cadastrada')?.last_sequence ?? 0
eventos = await eventBus.replayDeSequence(cursor, ['empresa.cadastrada'])
para cada evento em eventos:
tentar:
await this.onEmpresaCadastrada(evento)
capturar erro:
logger.error("Falha no replay — evento permanece pendente")
// 3. Registrar handler para eventos futuros
eventBus.inscrever('empresa.cadastrada', 'E-2', this.onEmpresaCadastrada.bind(this))
logger.log("E-2 inicializada — consumidor de empresa.cadastrada")

O algoritmo usa Decimal (via biblioteca decimal.js) para todas as operações monetárias. O tipo number do JavaScript (IEEE 754 double) não é adequado para valores financeiros — 0.1 + 0.2 !== 0.3 em floating point.

import Decimal from 'decimal.js';
function redistribuir(salariosBrutos: number[], parametro: number): RedistribuicaoResultado {
const n = salariosBrutos.length;
if (n <= 1) { /* ... retorno trivial ... */ }
const salarios = salariosBrutos.map(s => new Decimal(s));
// Criar pares (indice, salario) e ordenar por salario
const pares = salarios.map((s, i) => ({ i, s }));
pares.sort((a, b) => a.s.comparedTo(b.s));
const ordenados = pares.map(p => p.s);
const menor = ordenados[0];
const maior = ordenados[n - 1];
const razaoAtual = maior.div(menor);
if (razaoAtual.lte(parametro)) {
return semRedistribuicao(salariosBrutos, razaoAtual.toNumber());
}
// Log-compression com Decimal
// ln(x) via Decimal.ln()
const logs = ordenados.map(s => Decimal.ln(s));
const rangeLog = logs[n - 1].minus(logs[0]);
const alpha = Decimal.ln(parametro).div(rangeLog);
const logsComprimidos = logs.map(l =>
logs[0].plus(l.minus(logs[0]).times(alpha))
);
const temporarios = logsComprimidos.map(l => Decimal.exp(l));
// Rescaling
const custoTotal = ordenados.reduce((a, b) => a.plus(b), new Decimal(0));
const somaTemp = temporarios.reduce((a, b) => a.plus(b), new Decimal(0));
const fatorEscala = custoTotal.div(somaTemp);
const novosOrdenados = temporarios.map(t => t.times(fatorEscala));
// Desordenar
const novosSalarios = new Array(n);
for (let k = 0; k < n; k++) {
const indiceOriginal = pares[k].i;
novosSalarios[indiceOriginal] = novosOrdenados[k].toDecimalPlaces(2).toNumber();
}
// Métricas
let acimaTeto = 0;
let abaixoMinimo = 0;
let redistribuicaoTotal = new Decimal(0);
for (let i = 0; i < n; i++) {
const diff = novosOrdenados[pares.findIndex(p => p.i === i)]
.minus(salarios[i]);
if (diff.lt(-0.005)) {
acimaTeto++;
redistribuicaoTotal = redistribuicaoTotal.plus(diff.abs());
} else if (diff.gt(0.005)) {
abaixoMinimo++;
}
}
return {
novosSalarios,
razaoAtual: razaoAtual.toNumber(),
razaoNova: parametro,
custoTotal: custoTotal.toNumber(),
redistribuicaoTotal: redistribuicaoTotal.toDecimalPlaces(2).toNumber(),
acimaTeto,
abaixoMinimo,
};
}
Caso Comportamento
Empresa não cadastrada na projeção e2.empresas_projecao HTTP 404. A empresa precisa ser cadastrada via E-1 primeiro.
Identificador malformado nas rotas de diagnóstico HTTP 400 pelo ParseUUIDPipe (UUID v4).
Paginação inválida no histórico HTTP 400. page a partir de 1 e limit entre 1 e 50, com padrão 20.
Empresa com status recusada ou pendente HTTP 409. Apenas empresas cadastrada ou socializada podem submeter folha.
Apenas 1 cargo na folha Razão = 1.0 (sempre dentro do parâmetro). Algoritmo retorna sem alterações.
Todos os salários iguais Razão = 1.0. Sem alterações.
Razão atual já dentro do parâmetro (ex: 8x com parâmetro 10x) Algoritmo retorna sem alterações. Diagnóstico publicado com redistribuicao_total = 0, acima_teto = 0, abaixo_minimo = 0.
Razão exatamente igual ao parâmetro (ex: 10.0 com parâmetro 10.0) razaoAtual <= parametro → sem alterações.
Salário muito baixo (ex: R$1,00) com salário muito alto (ex: R$10.000.000,00) Alpha pequeno, compressão forte. Aritmética decimal garante precisão. Verificar que temporarios[0] * fatorEscala >= 0.01 (não zera por underflow).
Dois cargos com mesmo nome e mesmo salário Tratados como registros distintos. O algoritmo processa normalmente.
Dois cargos com mesmo nome e salários diferentes Tratados como registros distintos. Cada um recebe seu próprio ajuste baseado na posição na distribuição.
Submissão duplicada (mesmos dados) HTTP 409. idempotencia_hash único detecta, sem janela de tempo.
Submissão com cargos alterados Nova submissão legítima, com hash diferente.
CSV com encoding inválido (ex: Latin1 em vez de UTF-8) O parser de CSV falha ao encontrar caracteres inválidos nos nomes de cargo. HTTP 400 com mensagem descritiva.
CSV com delimitador incorreto O parser tenta vírgula primeiro, depois ponto-e-vírgula. Se nenhum funcionar, o cabeçalho não será reconhecido. HTTP 400.
INSERT folha_submissao falha com unique violation (race condition) Capturado. Busca registro existente. HTTP 409.
Float overflow no cálculo de exp(l) para salários extremos Decimal.exp() suporta valores até ~10^13 com precisão. Para salários de até 1 trilhão (DECIMAL(15,2)), não há risco de overflow.
Custo total após redistribuição difere do original por centavos Aceitável. A diferença é exclusivamente de arredondamento (cada salário arredondado para 2 casas decimais). A diferença máxima teórica é N × 0.005 centavos. Para 1000 cargos, no máximo R$5,00. O diagnóstico reporta os valores reais (custo_total_depois pode ser R$0,01 a R$5,00 diferente).
Falha de publicação de qualquer um dos dois eventos Erro logado, HTTP 500. O registro correspondente permanece com evento_publicado_em = NULL e a varredura de órfãos do boot republica.

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

@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException("Token não fornecido");
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, obterSegredoJwt());
request.representante_id = payload.sub;
return true;
} catch {
throw new UnauthorizedException("Token inválido ou expirado");
}
}
}

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

Rota Limite Janela Chave
POST /empresas/:id/folha 10 1 hora IP
POST /empresas/:id/folha/csv 10 1 hora IP
GET /empresas/:id/diagnostico 60 1 minuto IP
GET /empresas/:id/diagnosticos 60 1 minuto IP
GET /diagnosticos/:id 60 1 minuto IP

Algoritmo de compressão logarítmica em vez de cap-and-distribute linear. O algoritmo original do Apêndice B descrevia um cap-and-distribute simples: teto = menor × parâmetro, reduz acima, distribui abaixo. Essa abordagem preserva o custo total mas achata os extremos de forma abrupta — cargos próximos ao teto podem terminar com salários iguais, eliminando a hierarquia salarial entre posições adjacentes. A compressão logarítmica resolve esse problema: a transformação ln → compress → exp é um mapeamento contínuo e monotônico que preserva a ordenação e comprime proporcionalmente os gaps. Dois cargos com 20% de diferença antes da compressão mantêm uma diferença de ~9.5% depois (para alpha = 0.5) — menor, mas ainda significativa e proporcional. O teto e o piso não são thresholds rígidos, mas emergem naturalmente da distribuição.

decimal.js para aritmética, não number nativo. Valores financeiros exigem precisão decimal exata. 0.1 + 0.2 = 0.30000000000000004 em IEEE 754. Decimal(0.1).plus(0.2) retorna Decimal(0.3). A biblioteca decimal.js é a referência no ecossistema Node.js para aritmética de precisão arbitrária. Alternativas como big.js ou bignumber.js são equivalentes — a escolha específica é menos importante que a decisão de não usar ponto flutuante nativo.

Processamento síncrono no controller. O algoritmo é O(n log n) e processa 1000 cargos em < 10ms. Publicar o evento e processar assincronamente adicionaria latência de polling para o front-end sem ganho de throughput no MVP (monolito de processo único). Se a Fase 2 introduzir processamento pesado (ex: validação cruzada com tabela de mercado, enriquecimento estatístico), a lógica migra do controller para um handler de evento sem alterar contratos.

UPSERT em e2.empresas_projecao, não INSERT cego. Uma empresa pode ter seu status alterado (ex: de cadastrada para socializada). A E-2 precisa refletir essa mudança para permitir ou bloquear submissões futuras. O UPSERT cobre tanto a criação inicial quanto atualizações. O campo atualizado_em permite auditoria de quando a projeção foi atualizada pela última vez.


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-2 injeta EventBusService (do módulo @Global() N-0a) para três operações: publicar() dentro de publicarComRetry (dois tipos de evento, empresa.folha_submetida e empresa.diagnóstico_salarial_publicado), inscrever() (um tipo, empresa.cadastrada) e replayDeSequence() (um tipo na inicialização).

@Injectable()
export class FolhaService {
constructor(
private readonly eventBus: EventBusService,
private readonly algoritmo: AlgoritmoService,
private readonly csvService: CsvFolhaService,
private readonly empresaProjecaoRepo: EmpresaProjecaoRepository,
private readonly folhaRepo: FolhaSubmissaoRepository,
private readonly diagnosticoRepo: DiagnosticoRepository,
) {}
async submeter(empresaId: string, dto: SubmeterFolhaDto, representanteId: string) {
// ... validação, persistência ...
await publicarComRetry(this.eventBus, {
tipo: 'empresa.folha_submetida',
origem: 'E-2',
event_id: submissaoId,
correlacao_id: submissaoId,
payload: { /* ... */ },
});
// ... algoritmo, persistência diagnóstico ...
await publicarComRetry(this.eventBus, {
tipo: 'empresa.diagnóstico_salarial_publicado',
origem: 'E-2',
event_id: diagnosticoId,
correlacao_id: submissaoId,
payload: { /* ... */ },
});
}
}
@Injectable()
export class ProjecaoEmpresasService {
constructor(
private readonly eventBus: EventBusService,
private readonly empresaProjecaoRepo: EmpresaProjecaoRepository,
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(
'empresa.cadastrada', 'E-2',
this.onEmpresaCadastrada.bind(this),
);
}
}

5.2 Fluxo de eventos — cadeia completa de diagnóstico salarial

Seção intitulada “5.2 Fluxo de eventos — cadeia completa de diagnóstico salarial”
Front-end (formulário de folha — JSON ou CSV)
→ POST /empresas/:id/folha ou /empresas/:id/folha/csv (BFF E-2)
→ empresa.folha_submetida (barramento)
→ [processamento síncrono do algoritmo]
→ empresa.diagnóstico_salarial_publicado (barramento)
→ E-3 (Simulação Econômica — custo_total_depois)
→ D-7 (Transparência — timeline e dashboard)
E-1 → empresa.cadastrada (barramento)
→ E-2 (ProjecaoEmpresasService — atualiza e2.empresas_projecao)

A E-2 consome empresa.cadastrada da E-1 para manter projeção local de empresas. A E-2 não consulta a E-1 diretamente. O empresa_id no evento é suficiente para identificar a empresa.

A E-2 não consome empresa.associação_territorial_definida (E-1). A informação territorial não é necessária para o diagnóstico salarial — a relação entre salários é interna à empresa e independe de onde ela opera.

A E-3 consome dois eventos da E-2: empresa.diagnóstico_salarial_publicado (para obter custo_total_depois — a folha reequilibrada) e empresa.folha_submetida (para obter total_colaboradores). A E-3 usa esses valores na fórmula do excedente: excedente = receita - custos_operacionais - custo_total_depois, e no cálculo do valor por trabalhador: valor_por_trabalhador = (excedente × percentual_trabalhadores) / total_colaboradores. Com os parâmetros iniciais, o percentual é 20%. A E-3 não precisa consultar a E-2 — ambos os eventos contêm todos os dados necessários.

A E-2 não sabe que a E-3 existe. A E-2 publica o diagnóstico e a submissão de folha, e seus contratos estão cumpridos.

A D-7 consome empresa.diagnóstico_salarial_publicado para exibição pública na timeline da empresa e no dashboard de transparência. O payload do evento contém apenas agregados — a D-7 nunca expõe salários individuais.

A E-2 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 projeção de empresas é mantida localmente via eventos — sem chamada à E-1.

A E-2 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-2 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-2 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/:id/folha 10 1 hora IP @nestjs/throttler in-memory
POST /empresas/:id/folha/csv 10 1 hora IP @nestjs/throttler in-memory
GET /empresas/:id/diagnostico 60 1 minuto IP @nestjs/throttler in-memory
GET /empresas/:id/diagnosticos 60 1 minuto IP @nestjs/throttler in-memory
GET /diagnosticos/:id 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 JSON máximo do POST 1 MB Suficiente para ~1.000 cargos com nomes de até 200 caracteres.
Arquivo CSV máximo 1 MB Equivalente ao JSON. ~1.000 linhas com ~200 caracteres por linha.
cargo (nome) 200 caracteres Suficiente para cargos descritivos (“Coordenador de Operações Logísticas Regionais”).
salario máximo 999.999.999.999,99 Limite de DECIMAL(15,2). 1 trilhão, suficiente para qualquer folha.
salario mínimo 0,01 1 centavo. Aresta de validação, não caso prático.
Número máximo de cargos 1.000 Suficiente para a maior empresa do MVP. Acima disso, submissão em lotes na Fase 2.
jornada 20 caracteres ‘integral’ ou ‘meio_periodo’.
Índice Query atendida
empresas_projecao_pkey (empresa_id) Validação de existência da empresa — todo POST
empresas_projecao_status_idx Filtro de empresas ativas (futuro dashboard)
empresas_projecao_representante_id_idx Autorização da submissão pelo representante
folhas_submissoes_pkey (id) Acesso direto por submissao_id
folhas_submissoes_idempotencia_hash_key (UNIQUE) Verificação de idempotência — todo POST
folhas_submissoes_empresa_id_idx “Submissões da empresa X”
diagnosticos_pkey (id) GET /diagnosticos/:id
diagnosticos_submissao_id_key (UNIQUE) Um diagnóstico por submissão
diagnosticos_empresa_id_idx GET /empresas/:id/diagnostico (mais recente)
diagnosticos_empresa_id_criado_em_idx GET /empresas/:id/diagnosticos (histórico paginado)
consumer_offset_pkey (tipo_evento) Inicialização: WHERE tipo_evento = ?

O padrão de acesso é majoritariamente INSERT no POST, SELECT para leitura de diagnóstico:

  • buscarPorId() (projeção): 1 query por POST. Coberta pela PK.
  • buscarPorIdempotenciaHash(): 1 query por POST. Coberta pela unique.
  • inserir() (folha_submissao + diagnostico): 2 INSERTs por POST.
  • buscarMaisRecentePorEmpresa() (diagnostico): 1 query por GET, ORDER BY criado_em DESC LIMIT 1. Coberta por diagnosticos_empresa_id_criado_em_idx.
  • buscarPorEmpresa() (diagnosticos paginado): 1 query por GET com ORDER BY criado_em DESC LIMIT ? OFFSET ? mais o count. Coberta por diagnosticos_empresa_id_criado_em_idx.
  • buscarSemEventoPublicado() (varredura de órfãos): 1 query por boot em folhas_submissoes e diagnosticos, limitada a LIMITE_VARREDURA_ORFAOS.
Número de cargos Tempo estimado (Node.js, Decimal.js) Memória
10 < 1ms < 1 KB
100 < 2ms ~10 KB
1.000 < 10ms ~100 KB
10.000 ~50ms ~1 MB

O algoritmo é O(n log n) devido à ordenação. Para o limite de 1.000 cargos no MVP, o processamento é imperceptível para o usuário (< 10ms). A biblioteca decimal.js é mais lenta que number nativo (fator ~10x), mas para 1.000 operações ainda é < 10ms.

Sem cache na E-2. Justificativas:

  • A query de idempotência é por UNIQUE — < 1ms.
  • A query de diagnóstico mais recente é por índice composto — < 1ms.
  • O volume de leitura é baixo (consulta ao diagnóstico é feita pelo representante da empresa, não pelo público — o público vê o diagnóstico via D-7).
  • A complexidade de cache não se justifica no MVP.
Cenário Empresas ativas Submissões/ano Diagnósticos/ano Tamanho estimado do banco (ano)
PoC (5 empresas entusiastas) ~5 ~20 ~20 < 1 MB
MVP (1 município, dezenas de empresas) ~50 ~200 ~200 < 5 MB
Fase 2 (regional) ~5.000 ~20.000 ~20.000 < 100 MB

O crescimento é linear com a base de empresas e frequência de submissão. A tabela e2.diagnosticos é a que mais cresce (~2 KB por registro com JSONB). Para 20.000 diagnósticos/ano, ~40 MB. Particionamento por ano pode ser considerado na Fase 2, mas não se justifica na Fase 1.


Teste unitário do AlgoritmoService (zero dependências externas):

describe('AlgoritmoService', () => {
let service: AlgoritmoService;
beforeEach(() => {
service = new AlgoritmoService();
});
it('deve retornar sem alterações quando razão já está dentro do parâmetro', () => {
const resultado = service.redistribuir([1000, 1500, 3000, 5000], 10);
expect(resultado.novosSalarios).toEqual([1000, 1500, 3000, 5000]);
expect(resultado.redistribuicaoTotal).toBe(0);
});
it('deve comprimir distribuição com razão 100x para 10x', () => {
const resultado = service.redistribuir([1000, 100000], 10);
expect(resultado.novosSalarios[1] / resultado.novosSalarios[0]).toBeCloseTo(10, 1);
expect(resultado.custoTotal).toBeCloseTo(
resultado.novosSalarios.reduce((a, b) => a + b, 0),
0
);
});
it('deve preservar custo total constante', () => {
const salarios = [1000, 1500, 3000, 10000, 100000];
const custoOriginal = salarios.reduce((a, b) => a + b, 0);
const resultado = service.redistribuir(salarios, 10);
const custoNovo = resultado.novosSalarios.reduce((a, b) => a + b, 0);
expect(custoNovo).toBeCloseTo(custoOriginal, 0); // tolerância de centavos
});
it('deve preservar ordenação dos salários', () => {
const salarios = [5000, 1000, 100000, 3000, 1500];
const resultado = service.redistribuir(salarios, 10);
// salarios originais em ordem de índice: [5000, 1000, 100000, 3000, 1500]
// O índice 1 (1000) deve continuar sendo o menor
// O índice 2 (100000) deve continuar sendo o maior
const sorted = [...resultado.novosSalarios].sort((a, b) => a - b);
expect(resultado.novosSalarios[1]).toBe(sorted[0]);
expect(resultado.novosSalarios[2]).toBe(sorted[sorted.length - 1]);
});
it('deve retornar array vazio para entrada vazia', () => {
const resultado = service.redistribuir([], 10);
expect(resultado.novosSalarios).toEqual([]);
expect(resultado.razaoAtual).toBe(1);
});
it('deve retornar sem alterações para array de 1 elemento', () => {
const resultado = service.redistribuir([5000], 10);
expect(resultado.novosSalarios).toEqual([5000]);
expect(resultado.acimaTeto).toBe(0);
expect(resultado.abaixoMinimo).toBe(0);
});
});

Teste unitário do FolhaService:

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
FolhaService,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: AlgoritmoService, useValue: mockAlgoritmoService },
{ provide: CsvFolhaService, useValue: mockCsvService },
{ provide: FolhaSubmissaoRepository, useValue: mockFolhaRepo },
{ provide: DiagnosticoRepository, useValue: mockDiagnosticoRepo },
{ provide: EmpresaProjecaoRepository, useValue: mockEmpresaProjecaoRepo },
],
}).compile();
service = module.get(FolhaService);
mockEmpresaProjecaoRepo.buscarPorId.mockResolvedValue({
empresa_id: 'empresa-1',
razao_social: 'Padaria Pão Dourado Ltda',
porte: 'pequena',
representante_id: 'cidadao-1',
status: 'cadastrada',
});
mockFolhaRepo.buscarPorIdempotenciaHash.mockResolvedValue(null);
mockFolhaRepo.inserir.mockResolvedValue(undefined);
mockDiagnosticoRepo.inserir.mockResolvedValue(undefined);
mockEventBus.publicar.mockResolvedValue({ sequence_number: 1 });
mockAlgoritmoService.redistribuir.mockReturnValue({
novosSalarios: [1320, 2640, 10560, 7920],
razaoAtual: 50,
razaoNova: 10,
custoTotal: 22440,
redistribuicaoTotal: 10560,
acimaTeto: 1,
abaixoMinimo: 3,
});
});

Happy path:

# Cenário Verificação
T1 POST /empresas/:id/folha com JSON válido (3 cargos, razão dentro do parâmetro) HTTP 201. diagnostico.redistribuicao_total = 0. publicar() chamado com empresa.folha_submetida e empresa.diagnóstico_salarial_publicado.
T2 POST /empresas/:id/folha com JSON válido (5 cargos, razão 100x, parâmetro 10x) HTTP 201. diagnostico.razao_atual = 100. diagnostico.razao_parametrizada = 10. diagnostico.redistribuicao_total > 0. cargos_processados com 5 itens.
T3 POST /empresas/:id/folha/csv com CSV válido (multipart, campo arquivo) HTTP 201. Mesmo comportamento de T1/T2. formato_entrada = 'csv'.
T4 GET /empresas/:id/diagnostico HTTP 200. Diagnóstico mais recente, apenas agregados.
T5 GET /empresas/:id/diagnosticos?page=1&limit=10 HTTP 200. Array paginado com total, page, limit.
T6 GET /diagnosticos/:id HTTP 200. Diagnóstico específico por ID, apenas agregados.
T7 iniciar() sem eventos perdidos obterMaiorSequence() e replayDeSequence() chamados. inscrever() registrado para empresa.cadastrada.
T8 onEmpresaCadastrada() com payload válido UPSERT em e2.empresas_projecao com representante_id. Cursor avançado.

Falhas e bordas:

# Cenário Verificação
T9 POST /empresas/:id/folha para empresa inexistente HTTP 404. “Empresa não encontrada”.
T10 POST /empresas/:id/folha para empresa com status recusada HTTP 409. “Empresa não está ativa para submissão de folha”.
T11 POST /empresas/:id/folha por cidadão que não é o representante HTTP 403. “Empresa não pertence ao representante autenticado”.
T12 POST /empresas/:id/folha com salário zero ou negativo HTTP 400. “Salário inválido para o cargo X”.
T13 POST /empresas/:id/folha com mais de 1000 cargos HTTP 400. “Máximo de 1000 cargos por submissão”.
T14 POST /empresas/:id/folha/csv com CSV de encoding inválido HTTP 400 com a mensagem do parser.
T15 POST /empresas/:id/folha/csv com CSV sem coluna cargo HTTP 400. “CSV deve ter colunas: cargo,salario[,jornada]”.
T16 POST /empresas/:id/folha/csv com arquivo > 1 MB HTTP 413. “Arquivo muito grande. Máximo: 1 MB”.
T17 POST /empresas/:id/folha/csv sem o campo arquivo HTTP 400. “Arquivo CSV não fornecido (campo multipart arquivo)”.
T18 POST /empresas/:id/folha duplicada (mesmo conteúdo) HTTP 409. { submissao_id, duplicata: true }. publicar() NÃO chamado.
T19 POST /empresas/:id/folha excedendo rate limit (11ª submissão na hora) HTTP 429.
T20 POST /empresas/:id/folha sem JWT HTTP 401. “Token não fornecido”.
T21 POST /empresas/:id/folha com JWT inválido HTTP 401. “Token inválido ou expirado”.
T22 POST /empresas/:id/folha com todos os salários iguais HTTP 201. diagnostico.razao_atual = 1.0. redistribuicao_total = 0.
T23 POST /empresas/:id/folha com 1 único cargo HTTP 201. razao_atual = 1.0. Sem redistribuição.
T24 POST /empresas/:id/folha com publicação de evento falhando HTTP 500. Registros no banco com evento_publicado_em = NULL.
T25 POST /empresas/:id/folha com algoritmo lançando exceção HTTP 500. Submissão bruta persistida. Diagnóstico NÃO persistido.
T26 onEmpresaCadastrada() recebendo evento duplicado UPSERT, idempotente. Nenhum erro.
T27 onEmpresaCadastrada() recebendo empresa com status recusada UPSERT normalmente. status = 'recusada'. Submissões futuras bloqueadas.
T28 onEmpresaCadastrada() com payload incompleto ou status inválido Log.error, cursor avançado, projeção intacta.
T29 CSV com delimitador ponto-e-vírgula Detectado e processado corretamente.
T30 CSV com valores formatados (R$ 1.000,00) Parse remove “R$”, “.”, substitui “,” por “.”. Salário = 1000.00.

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

# Cenário Verificação
T31 Ciclo completo: POST folha → diagnóstico → GET 1 linha em e2.folhas_submissoes. 1 linha em e2.diagnosticos. 2 eventos em event_log (folha_submetida, diagnóstico_salarial_publicado). GET retorna diagnóstico.
T32 Ciclo completo: empresa.cadastrada → projeção local 1 linha em e2.empresas_projecao. POST folha para mesma empresa funciona.
T33 Idempotência com PostgreSQL real: dois POSTs idênticos Primeira: 201. Segunda: 409. count(*) em e2.folhas_submissoes = 1.
T34 Replay após reinício: eventos no log, empresa não projetada iniciar() processa empresa.cadastrada pendente. Projeção atualizada.
T35 Varredura de órfãos no boot Submissão sem evento e diagnóstico sem evento são republicados e marcados.
T36 Custo total constante: soma ANTES == soma DEPOIS (± centavos) abs(custo_total_antes - custo_total_depois) <= 5.00 (tolerância de arredondamento).
T37 Algoritmo: gaps proporcionais preservados Para dois cargos adjacentes A e B, se B/A = 1.5 originalmente, após compressão com alpha=0.5, novo gap ≈ 1.5^0.5 ≈ 1.225.

O prisma/seed-dev.ts não popula as tabelas de empresa. O SQL abaixo serve para teste manual local. O cursor é seedado no boot com obterMaiorSequence().

-- Empresa projetada (deve bater com o seed manual da E-1)
INSERT INTO e2.empresas_projecao (empresa_id, razao_social, porte, representante_id, status)
VALUES (
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'Padaria Pão Dourado Ltda',
'pequena',
'b2c3d4e5-f6a7-8901-bcde-f12345678901', -- cidadão_id do representante
'cadastrada'
);
-- Segunda empresa para testes de múltiplas
INSERT INTO e2.empresas_projecao (empresa_id, razao_social, porte, representante_id, status)
VALUES (
'b2c3d4e5-f6a7-8901-bcde-f12345678901',
'Tech Solutions S.A.',
'media',
'b2c3d4e5-f6a7-8901-bcde-f12345678901',
'cadastrada'
);
-- Submissão de folha de exemplo (razão 100x)
INSERT INTO e2.folhas_submissoes (id, empresa_id, total_colaboradores, cargos, formato_entrada, idempotencia_hash, evento_publicado_em)
VALUES (
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
5,
'[
{"cargo": "Auxiliar de Padaria", "salario": 1000, "jornada": "integral"},
{"cargo": "Atendente", "salario": 1500, "jornada": "integral"},
{"cargo": "Padeiro", "salario": 3000, "jornada": "integral"},
{"cargo": "Gerente de Loja", "salario": 10000, "jornada": "integral"},
{"cargo": "Diretor", "salario": 100000, "jornada": "integral"}
]'::jsonb,
'form',
'hash-exemplo-submissao-1',
'2026-06-15T14:00:00Z'
);
-- Diagnóstico correspondente
INSERT INTO e2.diagnosticos (id, submissao_id, empresa_id,
menor_salario, maior_salario, razao_atual, razao_parametrizada,
custo_total_antes, custo_total_depois, redistribuicao_total,
colaboradores_acima_teto, colaboradores_abaixo_minimo,
versao_parametros, cargos_processados, payload_diagnostico, evento_publicado_em)
VALUES (
'd4e5f6a7-b8c9-0123-defa-123456789abc',
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
1000.00,
100000.00,
100.0000,
10.00,
115500.00,
115507.00,
32521.00,
1,
4,
'mvp-v1',
'[
{"cargo": "Auxiliar de Padaria", "salario_antes": 1000.00, "salario_depois": 6748.00, "variacao_percentual": 574.80},
{"cargo": "Atendente", "salario_antes": 1500.00, "salario_depois": 8264.00, "variacao_percentual": 450.93},
{"cargo": "Padeiro", "salario_antes": 3000.00, "salario_depois": 11688.00, "variacao_percentual": 289.60},
{"cargo": "Gerente de Loja", "salario_antes": 10000.00, "salario_depois": 21328.00, "variacao_percentual": 113.28},
{"cargo": "Diretor", "salario_antes": 100000.00, "salario_depois": 67479.00, "variacao_percentual": -32.52}
]'::jsonb,
'{
"empresa_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"submissao_id": "c3d4e5f6-a7b8-9012-cdef-123456789012",
"menor_salario": 1000.00,
"maior_salario": 100000.00,
"razao_atual": 100.0000,
"razao_parametrizada": 10.00,
"custo_total_antes": 115500.00,
"custo_total_depois": 115507.00,
"redistribuicao_total": 32521.00,
"colaboradores_acima_teto": 1,
"colaboradores_abaixo_minimo": 4,
"versao_parametros": "mvp-v1"
}'::jsonb,
'2026-06-15T14:00:01Z'
);

Para testes de idempotência, usar idempotencia_hash gerado deterministicamente a partir dos mesmos dados do seed. Para testes de rate limiting, configurar THROTTLE_TTL e THROTTLE_LIMIT nas variáveis de ambiente de teste.


Funcionalidade Status
POST /empresas/:id/folha com JSON (formulário): validação, persistência, algoritmo e publicação de eventos MVP obrigatório
POST /empresas/:id/folha/csv com CSV multipart: parsing interno, validação, persistência, algoritmo e publicação de eventos MVP obrigatório
Algoritmo de compressão logarítmica com decimal.js, preservando custo total e gaps proporcionais MVP obrigatório
GET /empresas/:id/diagnostico — diagnóstico mais recente, apenas agregados; cargos_processados só na resposta autenticada do POST MVP obrigatório
GET /empresas/:id/diagnosticos — histórico paginado de diagnósticos MVP obrigatório
GET /diagnosticos/:id — diagnóstico específico por ID, apenas agregados MVP obrigatório
Publicação de empresa.folha_submetida sem cargos nem salários no payload MVP obrigatório
Publicação de empresa.diagnóstico_salarial_publicado com payload de agregados MVP obrigatório
Consumo de empresa.cadastrada para projeção local (e2.empresas_projecao) MVP obrigatório
Consumer offset para replay após falha (e2.consumer_offset) MVP obrigatório
Idempotência por idempotencia_hash (SHA-256 de empresa + representante + cargos ordenados, sem janela) MVP obrigatório
Validação de empresa e de representante contra a projeção local antes de processar folha MVP obrigatório
Autenticação via JWT do D-1a (AuthGuard) MVP obrigatório
Logs estruturados com empresa_id, submissao_id e diagnostico_id MVP obrigatório
Rate limiting nas rotas de submissão e de leitura MVP obrigatório
Varredura de órfãos no boot (FolhaService.iniciar()) MVP obrigatório
Simplificação Justificativa Quando remover
Parâmetro de razão salarial (10x) fixo em e2.constants.ts (RAZAO_MAXIMA, env E2_RAZAO_MAXIMA, padrão 10) Sem sistema de parametrização dinâmica em produção no MVP. O valor de referência do Apêndice B é suficiente para fechar o ciclo. Migrar para consumo do evento parâmetros.atualizados da D-19 na Fase 2.
Sem normalização de jornada (meio período vs. integral) no cálculo Complexidade de interpretação — um salário de meio período pode refletir senioridade, não apenas proporção de horas. O campo é armazenado como metadado. Adicionar como opção parametrizável na Fase 2 (flag normalizar_jornada).
Apenas cargos e salários — sem diferenciação por benefícios, bônus, PLR O MVP foca no salário base declarado. Benefícios e remuneração variável são camada adicional de complexidade. Adicionar campos opcionais beneficios, bonus, plr no schema do evento na Fase 2.
Sem comparação com mercado (salários de referência por cargo e setor) Exigiria base de dados externa de remuneração de mercado (ex: RAIS, CAGED) — fora do escopo do MVP. Adicionar colônia de enriquecimento estatístico na Fase 2.
Processamento síncrono no controller Algoritmo é < 10ms para 1.000 cargos. Assincronia não traz ganho no MVP. Migrar processamento para handler de evento se a Fase 2 adicionar etapas pesadas (validação cruzada, enriquecimento).
Atualização do representante_id da projeção apenas por empresa.cadastrada A validação de propriedade usa o campo gravado no cadastro. A troca de representante é rara e não tem evento próprio no MVP. Consumir evento empresa.representante_alterado quando a E-1 publicá-lo na Fase 2.
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 submissões legítimas de conteúdo diferente. 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 justificar.
  • Consumo do evento parâmetros.atualizados da D-19 para recalcular diagnósticos com novos parâmetros
  • Normalização de jornada como opção parametrizável
  • Campos adicionais na folha: beneficios, bonus, plr (remuneração variável)
  • Comparação com mercado: enriquecimento estatístico (mediana, P25, P75 por cargo e setor)
  • Processamento assíncrono via handler de evento (se necessário)
  • Consumo de empresa.representante_alterado para atualizar projeção
  • Consumo de empresa.status_alterado para refletir mudanças sem depender de replay completo de empresa.cadastrada
  • Endpoint de republicação de eventos (POST /empresas/:id/folhas/:id/republicar)
  • Scheduled job periódico de reconciliação (a varredura de boot já cobre o MVP)
  • Idempotency key via header Idempotency-Key (substitui o hash de conteúdo)
  • Rate limiting com Redis store
  • Métricas Prometheus: e2_folhas_submetidas_total, e2_diagnosticos_publicados_total, e2_razao_media, e2_redistribuicao_total
  • Análise de tendência: variação da razão ao longo do tempo para uma mesma empresa (série temporal de diagnósticos)
  • Exportação de diagnóstico em PDF/CSV para o representante da empresa

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: empresa.folha_submetida publicado pela E-2, mas o Registry define produtor como “Interface de empresa (BFF ou formulário dedicado)”. A E-2 atua como essa interface. O BFF da E-2 é o formulário dedicado que recebe a folha e publica o evento. O Registry não especifica qual colônia — apenas que é a interface de entrada. A E-2 acumula os papéis de BFF (entrada) e processador (diagnóstico). Sem conflito.

Conflito potencial: E-2 publica empresa.folha_submetida e a E-3 consome o evento. O processamento do diagnóstico ocorre no fluxo síncrono do controller, e a E-3 consome o evento apenas para o total_colaboradores. Não há auto-consumo de empresa.folha_submetida no MVP. Na Fase 2, se o processamento migrar para um handler interno, o controller deixa de processar e o contrato de eventos não muda. Sem conflito.

Conflito potencial: cargos_processados contém nomes de cargo — isso não é “sem identificação pessoal”? O Apêndice B especifica que a folha é “sem nomes — apenas cargo e valor”. O nome do cargo não identifica a pessoa (múltiplos colaboradores podem ter o mesmo cargo). O cargos_processados é retornado apenas na resposta autenticada do endpoint (requer JWT do representante) — não é publicado no evento empresa.diagnóstico_salarial_publicado. O evento público contém apenas agregados. Sem conflito.

Conflito potencial: empresa.diagnóstico_salarial_publicado tem campos que não existem no schema original do Apêndice B. O schema do Registry (N-0b seção 3.4.25) define os campos com precisão. A E-2 publica exatamente o que o Registry especifica. Os campos adicionais no estado próprio (cargos_processados, payload_diagnostico) são internos à E-2 — não vão no evento. Sem conflito.

Conflito potencial: menor_salario e maior_salario no evento referem-se aos valores ANTES da redistribuição. Confirmado. O schema do Registry não especifica “antes” ou “depois” explicitamente, mas o propósito do diagnóstico é mostrar a situação atual (antes) e o que seria possível (depois). A E-2 publica menor_salario e maior_salario como os valores originais declarados. O impacto da redistribuição é capturado por custo_total_depois, redistribuicao_total e razao_parametrizada. Se houver ambiguidade, a Fase 2 pode adicionar menor_salario_depois e maior_salario_depois como campos opcionais no schema v1.1.0 do evento. Sem conflito no MVP.

Conflito potencial: compartilhamento de JWT_SECRET entre D-1a e E-2. Mesmo padrão da 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. Sem conflito com a regra de isolamento.

Conflito potencial: E-2 com BFF acoplado vs. regra de que apenas D-1a tem BFF. Mesma justificativa da E-1 (seção 8.3 do documento E-1). A regra restringe comunicação entre colônias, não a existência de interfaces HTTP. O BFF da E-2 serve apenas o front-end de upload de folha e visualização de diagnóstico. Não faz chamadas HTTP para outras colônias. Sem conflito.



Documento de especificação técnica de implementação. Gerado a partir da ficha técnica em Apêndice B - Colônias.md e dos schemas de eventos em N-0b - Registry.md. Segue o padrão de especificação estabelecido por E-1 - Cadastro Institucional.md.