E-2 — Transparência Salarial e Folha
Parte das Colônias de Empresas — Fase 1
Propósito
Seção intitulada “Propósito”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 |
Referência
Seção intitulada “Referência”Especificação completa em Apêndice B - Colônias.md, seção “Colônia E-2 — Transparência Salarial e Folha”.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”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.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”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 evento1.2 Module definition
Seção intitulada “1.2 Module definition”@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(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- 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
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. OProjecaoEmpresasService.iniciar()faz o seed do cursor, o replay deempresa.cadastradaperdido e o registro do handler. OFolhaService.iniciar()faz a varredura de órfãos (submissões semempresa.folha_submetidapublicado e diagnósticos semempresa.diagnóstico_salarial_publicado). - O módulo não importa
ThrottlerModule.forRoot()— isso já é feito pela D-1a. O rate limiting usa@Throttledo 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.
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:
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 comnovosSalarios,razaoAtual,razaoNova,custoTotal,redistribuicaoTotal,acimaTetoeabaixoMinimo.CsvFolhaService.parse(conteudo): parser CSV interno.ProjecaoEmpresasService.iniciar()eonEmpresaCadastrada(evento): consumidor da projeção.- O
DiagnosticoControllerlê o repositório direto, sem service intermediário.
1.5 Controllers — endpoints expostos
Seção intitulada “1.5 Controllers — endpoints expostos”| 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). |
1.6 Colônia com BFF acoplado
Seção intitulada “1.6 Colônia com BFF acoplado”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.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema e2
Seção intitulada “2.1 Schema e2”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.
2.2 Tabela e2.empresas_projecao
Seção intitulada “2.2 Tabela e2.empresas_projecao”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.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.3 Tabela e2.folhas_submissoes
Seção intitulada “2.3 Tabela e2.folhas_submissoes”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);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.4 Tabela e2.diagnosticos
Seção intitulada “2.4 Tabela e2.diagnosticos”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);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
Nomes de cargo nos dados processados
Seção intitulada “Nomes de cargo nos dados processados”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.
2.5 Tabela e2.consumer_offset
Seção intitulada “2.5 Tabela e2.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 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).
2.6 Migrations
Seção intitulada “2.6 Migrations”20260813191200_create_e2_tables— Cria o schemae2, as tabelasempresas_projecao,folhas_submissoes,diagnosticoseconsumer_offset, com índices, uniques e a FK interna do diagnóstico.20260818090000_add_representante_id_empresas_projecao— Adicionarepresentante_ideme2.empresas_projecaocom índice.20260818120000_backfill_representante_id_empresas_projecao— Preenche orepresentante_iddas projeções existentes a partir dos eventos.
2.7 Relações internas
Seção intitulada “2.7 Relações internas”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.
2.8 Decisões de schema
Seção intitulada “2.8 Decisões de schema”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.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”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.
3.1 Evento produzido: empresa.folha_submetida
Seção intitulada “3.1 Evento produzido: empresa.folha_submetida”| 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)}3.3 Evento consumido: empresa.cadastrada
Seção intitulada “3.3 Evento consumido: empresa.cadastrada”| 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 }3.5 Ordem de operações — submeter folha (CSV)
Seção intitulada “3.5 Ordem de operações — submeter folha (CSV)”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 evento3.7 Idempotência na publicação de eventos
Seção intitulada “3.7 Idempotência na publicação de eventos”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.
3.8 Tratamento de erro e reentrega
Seção intitulada “3.8 Tratamento de erro e reentrega”| 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. |
3.9 Decisões de design com justificativa
Seção intitulada “3.9 Decisões de design com justificativa”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. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”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, }Demonstração numérica
Seção intitulada “Demonstração numérica”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.
4.2 FolhaService.submeter() — pseudocódigo
Seção intitulada “4.2 FolhaService.submeter() — pseudocódigo”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, }4.3 FolhaService.processarCsv() — pseudocódigo
Seção intitulada “4.3 FolhaService.processarCsv() — pseudocódigo”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')4.4 ProjecaoEmpresasService — pseudocódigo
Seção intitulada “4.4 ProjecaoEmpresasService — pseudocódigo”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")4.5 Aritmética decimal no algoritmo
Seção intitulada “4.5 Aritmética decimal no algoritmo”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, };}4.6 Casos de borda
Seção intitulada “4.6 Casos de borda”| 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. |
4.7 AuthGuard — validação de JWT do D-1a
Seção intitulada “4.7 AuthGuard — validação de JWT do D-1a”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"); } }}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.
| 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 |
4.9 Decisões de design com justificativa
Seção intitulada “4.9 Decisões de design com justificativa”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”5.1 Publicação e consumo via EventBusService
Seção intitulada “5.1 Publicação e consumo via EventBusService”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)5.3 Relação com a E-1
Seção intitulada “5.3 Relação com a E-1”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.
5.4 Relação com a E-3
Seção intitulada “5.4 Relação com a E-3”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.
5.5 Relação com a D-7
Seção intitulada “5.5 Relação com a D-7”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.
5.6 Chamadas síncronas via BFF
Seção intitulada “5.6 Chamadas síncronas via BFF”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.
5.7 Dependências de projeções de leitura
Seção intitulada “5.7 Dependências de projeções de leitura”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.
5.8 Compartilhamento de JWT_SECRET com o D-1a
Seção intitulada “5.8 Compartilhamento de JWT_SECRET com o D-1a”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.
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/: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.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| 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’. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Í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 = ? |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”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 pordiagnosticos_empresa_id_criado_em_idx.buscarPorEmpresa()(diagnosticos paginado): 1 query por GET comORDER BY criado_em DESC LIMIT ? OFFSET ?mais o count. Coberta pordiagnosticos_empresa_id_criado_em_idx.buscarSemEventoPublicado()(varredura de órfãos): 1 query por boot emfolhas_submissoesediagnosticos, limitada aLIMITE_VARREDURA_ORFAOS.
6.5 Performance do algoritmo
Seção intitulada “6.5 Performance do algoritmo”| 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.
6.6 Estratégia de cache
Seção intitulada “6.6 Estratégia de cache”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.
6.7 Projeção de volume
Seção intitulada “6.7 Projeção de volume”| 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.
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 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, });});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/: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. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”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últiplasINSERT 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 correspondenteINSERT 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.
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/: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 |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| 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. |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- Consumo do evento
parâmetros.atualizadosda 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_alteradopara atualizar projeção - Consumo de
empresa.status_alteradopara refletir mudanças sem depender de replay completo deempresa.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.
Referências
Seção intitulada “Referências”- Especificação de origem: Apêndice B - Colônias.md, seção “Colônia E-2 — Transparência Salarial e Folha”
- Colônias vizinhas no pipeline:
- E-1 - Cadastro Institucional.md — produz
empresa.cadastrada(consumido pela E-2) - E-3 - Simulação Econômica.md — consome
empresa.diagnóstico_salarial_publicado - D-7 - Transparência.md — consome
empresa.diagnóstico_salarial_publicado
- E-1 - Cadastro Institucional.md — produz
- Schemas de eventos:
- N-0b - Registry.md, seções 3.4.22 (
empresa.cadastrada), 3.4.24 (empresa.folha_submetida), 3.4.25 (empresa.diagnóstico_salarial_publicado), 3.4.31 (parâmetros.atualizados)
- N-0b - Registry.md, seções 3.4.22 (
- Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- BFF de referência (formato e padrão): E-1 - Cadastro Institucional.md, 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. 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.