# Anomalias e particularidades dos cálculos financeiros

Registro do que os testes de caracterização revelaram sobre o comportamento
atual dos cálculos. **Nada aqui deve ser corrigido durante a migração** —
corrigir invalida a comparação entre o sistema atual e o novo, que é o que
garante que ninguém saia no prejuízo. Cada item é uma decisão do negócio, a
tomar depois da virada.

Atualizado em 12/09/2026.

---

## Comportamentos confirmados por teste

### O lucro do relatório de produtos é calculado com o preço de HOJE

Achado em 12/09/2026, ao escrever
`tests/Caracterizacao/ProdutosNoRelatorioTest.php`. **É a maior das anomalias
registradas aqui, em dinheiro.**

`VendaProdutoRepository::relatorioVendasProduto()` soma multiplicando pela coluna
do PRODUTO, e não pela do item vendido:

    SUM(pro.valorCompra * venpro.quantidade) AS totalValorCompra
    SUM(pro.valorVenda  * venpro.quantidade) AS totalValorVenda

`pro.valorVenda` é o preço que o produto tem **hoje**; o que o cliente pagou está
em `venpro.valorUnitario`. E a view usa os dois para o lucro impresso
(`is-relatorio/produtos/index.phtml:80`):

    $lucroItem = ($produto['totalValorVenda'] - $produto['totalValorCompra'])
               - $produto['totalDesconto'];

**Subir o preço de um produto hoje aumenta o lucro de ontem no relatório.**

**O tamanho, medido na cópia e sobre dinheiro REAL:**

| | |
|---|---:|
| total pelo preço atual | R$ 199.892.468,97 |
| total efetivamente cobrado | R$ 156.450.215,47 |
| **diferença** | **R$ 43.442.253,50** (27,8%) |
| linhas de venda finalizada | 3.176.605 |

O número é de dinheiro de verdade, e isso foi conferido antes de ser escrito: a
anonimização reescreve nove tabelas — `clientes`, `usuarios`, `unidades`,
`agendas`, `agendas_exclusoes`, `notificacoes`, `push_app`, `unidades_socios` e
`visagismo` — e **nenhuma delas é `produtos`, `vendas` ou `vendas_produtos`**.
Número medido sobre coluna anonimizada seria ficção.

**Boa parte da diferença é deriva de preço ao longo dos dez anos da base — e essa
é exatamente a mecânica, não uma atenuante:** o relatório atribui os preços de
hoje às vendas de antigamente. Quanto mais velha a venda, mais errado o lucro
dela.

**Consequência para a migração:** nenhuma; os dois motores calculam igual, e o
teste grava o comportamento como está. **Não corrigir durante a virada.**

**Consequência operacional:** o lucro por produto está inflado, e a inflação é
proporcional ao reajuste acumulado de cada produto. A decisão — somar o cobrado
ou continuar somando o preço de tabela — é do negócio, e muda o número de uma tela
que alguém usa para decidir preço.

### O relatorio de descontos repete o item uma vez por pagamento da venda

Achado em 12/09/2026, ao escrever
`tests/Caracterizacao/DescontosNoRelatorioTest.php`.

`VendaRepository::listagemIndexRelatorioDescontos()` promete, no proprio
comentario, *"uma linha por item descontado"* -- a listagem foi reenraizada da
venda para o item em 26/08/2026 exatamente para isso, e para que *"contagem e
limite falem da mesma coisa"* na paginacao. Mas o `FROM` mantem, sempre:

    LEFT JOIN ven.pagamentos venpag
    LEFT JOIN venpag.formaPagamento pag

Os dois existem so para o filtro opcional por forma de pagamento, e
`ven.pagamentos` e uma COLECAO. Sem `GROUP BY` -- que a reenraizacao retirou de
proposito --, **uma venda paga em duas formas multiplica cada item descontado
dela por dois**.

**O TAMANHO DISSO HOJE, medido na copia anonimizada:**

| | |
|---|---:|
| itens descontados | 550.995 |
| linhas que o DQL devolve | 550.996 |
| vendas com desconto E mais de um pagamento | **1** |

Uma linha. O defeito e real e reprodutivel -- o cenario de fixtures tem a venda
90001210 paga em duas formas, e e ela que produz a linha a mais --, mas pagamento
misto com desconto e raro nesta base.

**Consequencia para a migracao:** nenhuma; os dois motores duplicam igual, e o
teste grava a duplicacao como comportamento de hoje, nao como erro a consertar no
meio da virada.

**Consequencia operacional:** a pagina de 20 mostra menos de 20 itens distintos
quando ha venda com pagamento misto, e o contador da paginacao volta a discordar
do limitador -- que e o defeito que a reenraizacao foi consertar. Se o pagamento
misto crescer nesta base, o numero cresce junto.


### A comissão não é recalculada no fechamento

`VendaPagamentoService::finalizar()` não recalcula a comissão dos itens: ela é
gravada em `vendas_produtos.comissao` no momento em que o item entra na comanda.
Confirmado por `FechamentoVendaTest::testComissaoDoItemNaoERecalculadaNoFechamento`,
que grava uma comissão propositalmente inconsistente com o valor do item e
verifica que o fechamento a preserva.

**Consequência para a migração:** a regra de comissão precisa ser reproduzida no
momento da **inclusão do item**, não no fechamento. Qualquer divergência de
comissão nasce ali.

**Consequência operacional:** se a regra de comissão de um colaborador mudar
enquanto há comandas abertas, as comandas abertas mantêm a comissão antiga.

### O fechamento troca o usuário da venda

A venda é criada com o usuário que abriu a comanda, mas `finalizar()` substitui
esse valor pelo **dono do caixa aberto**. Confirmado por
`testFechamentoDeServicoVinculaCaixaEAtualizaCliente`.

Ou seja, `vendas.usuario` identifica quem fechou no caixa, não quem atendeu. O
colaborador que executou o serviço fica apenas em `vendas_produtos.colaborador`.
Relatórios que agrupem por `vendas.usuario` estão medindo operação de caixa, não
atendimento.

### O desconto do item é acumulado na venda no fechamento

`vendas.desconto_total` é preenchido somando os `valor_desconto` dos itens
durante o fechamento, e `vendas.valor_total` já vem líquido. Confirmado por
`testDescontoDoItemEAcumuladoNoTotalDaVenda`.

### O fechamento libera a restrição de agendamento do cliente

`finalizar()` marca `clientes.cadastro_restricao_agendamento = 0`. Uma restrição
aplicada ao cadastro é, portanto, removida na primeira venda fechada, sem
intervenção de ninguém.

### O fechamento sobrescreve a data de criação da venda

A comanda é aberta com uma `data_criacao`, mas `finalizar()` chama
`setDataCriacao()` de novo. O campo passa a valer o momento do **fechamento**,
não o da abertura. Confirmado por
`PagamentoTrocoDebitoTest::testFechamentoSobrescreveADataDeCriacaoDaVenda`.

**Consequência:** relatórios de fluxo e movimento por horário medem fechamento
de caixa, não movimento de atendimento. Uma comanda aberta às 9h e fechada às
18h aparece inteiramente às 18h. Não existe hoje registro de quando a comanda
foi aberta — a informação é perdida no fechamento.

### O troco tem dois destinos, e o caixa é creditado de forma diferente em cada um

`finalizar()` aceita `destinoTroco`. Se valer `"cliente"`, o troco vira crédito
do cliente na unidade; qualquer outro valor é tratado como **id de colaborador**
e o troco vira **gorjeta**. Em ambos os casos o troco da venda é zerado depois.

O acúmulo no caixa muda conforme o caso:

| Situação | O que entra no caixa |
|---|---|
| Troco com destino (crédito ou gorjeta) | `valor_recebido` **inteiro** |
| Troco sem destino (devolvido em espécie) | `valor_recebido − valor_troco` |

Faz sentido — no primeiro caso o dinheiro não saiu da gaveta. Confirmado por
quatro testes em `PagamentoTrocoDebitoTest`.

### Venda paga a menor gera débito do cliente automaticamente

Se `valor_recebido < valor_total`, o fechamento cria um `ClienteDebito` com a
diferença, vinculado à venda e à unidade. É o fiado, e acontece **sem
confirmação**: qualquer venda fechada com valor recebido menor vira dívida do
cliente.

### Produto dá baixa no estoque, serviço não

O fechamento decrementa `produtos.quantidade` para todo item cujo tipo não seja
`ser`. Serviços não movimentam estoque.

### Os valores chegam ao serviço como string mascarada

`VendaPagamentoService::adicionar()` e `CaixaService::retirada()` recebem o valor
no **formato brasileiro** (`"1.500,00"`) e aplicam `Monetario::removeMask()`.
Confirmado por `PagamentoMistoTest::testValorChegaMascaradoNoFormatoBrasileiro`.

**Consequência para a migração:** uma API REST que envie `1500.00` como número
JSON seria interpretada como **1,50** — o ponto vira separador de milhar e os
decimais somem. A conversão precisa ser explícita na fronteira nova.

O mesmo vale na leitura: `Caixa::getValorFinal()` e `CaixaRetirada::getValor()`
retornam **string mascarada por padrão**; só com o argumento `false` devolvem
número. Todo o código de cálculo hoje passa `false` corretamente — auditei os
dez usos —, mas é uma armadilha para quem escrever código novo.

### O troco é recalculado de forma assimétrica

Ao **adicionar** pagamento, o troco é sempre recalculado. Ao **remover**, só é
recalculado se já houver troco maior que zero (`if ($venda->getValorTroco() > 0)`).
Na prática os cenários testados se comportam corretamente, mas a assimetria é
gratuita e vale reproduzir com cuidado — ou simplificar deliberadamente, com o
teste como rede.

### A pontuação de fidelidade tem base de cálculo restrita

`salvarPontuacao()` monta a base somando **apenas** itens de tipo `ser` (serviço)
e `probar` (produto de barbearia). Empório (`proemp`) e insumo (`proins`) ficam
de fora. Depois subtrai da base os pagamentos feitos com as formas de id **2** e
**6** — mais dois ids literais, somando-se aos já registrados.

A fórmula é `round(base) * unidade.cashback`, com o **arredondamento antes da
multiplicação**. Com cashback 10, uma venda de 89,90 gera 900 pontos e não 899.
Confirmado por `FidelidadePontuacaoTest::testOArredondamentoAconteceAntesDaMultiplicacao`.

### O saldo de pontos é mantido pelo chamador, não pelo fechamento

`salvarPontuacao()` só escreve o saldo em `clientes_creditos.pontuacao` quando
cria o registro de crédito. Da segunda compra em diante grava apenas o
histórico.

O saldo continua correto porque `VendasPagamentosController` chama
`ClienteService::atualizarPontuacao()` logo após `finalizar()`, e essa função
recalcula o saldo somando o histórico válido. São dois passos, e o segundo mora
no controller.

**Consequência para a migração:** uma API que chame apenas `finalizar()` deixa o
saldo congelado a partir da segunda compra do cliente. Os dois testes em
`FidelidadePontuacaoTest` cobrem as duas pontas — o serviço isolado e a
orquestração completa.

*Registro anterior corrigido:* este item já afirmou que os pontos não
acumulavam, o que gerou o débito D21. Era engano meu, por caracterizar o serviço
isoladamente e concluir sobre o sistema. D21 está cancelado.

### As duas fidelizações se comportam de formas opostas

`clientes.fidelizado_colaborador` marca que o cliente voltou com o **mesmo
profissional** da visita anterior, e **volta atrás**: trocar de profissional na
visita seguinte desmarca. Na primeira visita nunca marca, porque a comparação é
feita contra 0 (não havia profissional anterior).

`clientes.fidelizado_unidade` marca que o cliente atingiu o número de visitas
configurado dentro da janela de dias da unidade, e é **de mão única**: o código
só faz `setFidelizadoUnidade(true)`, sem nenhum ramo que devolva `false`. Uma vez
marcado, permanece mesmo que o cliente pare de aparecer.

Confirmado por sete testes em `FidelizacaoTest`.

### A venda em curso não conta para o limiar de fidelização

A contagem de visitas roda **antes** de `setStatus(1)` marcar a venda atual como
finalizada, e a consulta só conta vendas com status 1. Com limiar de 3 visitas, a
fidelização acontece na **quarta** visita, não na terceira.

Não é necessariamente errado — depende de o negócio querer contar "visitas
anteriores" ou "visitas incluindo esta". Mas é um deslocamento de um que precisa
ser reproduzido igual, ou os relatórios de fidelização divergem entre o sistema
atual e o novo.

### Cuidado: `fidelizado_unidade` parece referência e é booleano

A coluna é `int` e o nome sugere o id da unidade, mas a entidade a mapeia como
`type="boolean"` e o código grava `true`. Esta caracterização começou com a
fixture semeando 1000 ali, e os testes acusaram na hora. Vale o registro para
quem for migrar o schema: o tipo correto no PostgreSQL é `boolean`.

### Resgate de pontos: 20 por real, tudo de uma vez

`programaFidelidadeResgatarPontuacao()` converte pontos em dinheiro com o
divisor **20 fixo no código** — 20 pontos valem R$ 1,00 — e o valor vai para
`clientes_creditos.valor_pf`, o crédito do programa de fidelidade, que é um
saldo distinto do crédito comum (`valor`).

**Não existe resgate parcial.** Todas as entradas ativas do extrato viram
`resgatada` de uma vez e o saldo é zerado, independentemente do valor do prêmio.
O sistema também grava um lançamento negativo no extrato, já nascido com status
`resgatada` para não voltar a contar na soma.

Confirmado por quatro testes em `FidelidadeResgateVencimentoTest`.

### Pontos vencem em um ano

`expirarPontuacao()` marca como `expirada` toda entrada `ativa` com
`DATE(data_criacao) < hoje − 1 ano`. A comparação é estritamente menor, então o
dia exato do aniversário de um ano ainda vale.

O saldo recalculado soma apenas entradas `ativa`, de modo que tanto o vencimento
quanto o resgate saem da conta automaticamente.

### Royalties: retenção por categoria e desconto de fidelidade

A retenção é `faturamento da categoria × alíquota / 100`, com alíquotas por
unidade e por tipo. São cinco categorias: serviço (que inclui `pac`), Lava VIP,
produto de barbearia e empório — o empório entra no `fat_total` mas tem alíquota
própria. Confirmado por três testes em `RoyaltiesTest`.

O total retido recebe ainda um abatimento proporcional ao uso de crédito do
programa de fidelidade:

```php
$ret_total = $ret_total - ($ret_total * ($cred_prog_fide_percental / 100));
```

Ou seja, quanto mais o cliente paga com pontos, menos royalty a unidade recolhe.

Detalhe de implementação frágil: `fat_ser` acumula com `+=` enquanto as demais
categorias usam `=`. Funciona porque a consulta agrupa por tipo e cada um
aparece uma vez só, mas basta a consulta mudar para as outras categorias
passarem a guardar apenas a última linha.

### Os campos de Lava VIP são gravados cruzados

`royaltiesAtualizar()` grava a retenção na coluna de faturamento e vice-versa,
apenas para o Lava VIP. A **cobrança não é afetada** — a fatura usa um total
somado antes do cruzamento —, mas o relatório de franquias que lê a tabela
persistida mostra as duas colunas trocadas. Ver débito D22.

### O caixa tem duas contabilidades que podem divergir

Durante a operação, `finalizar()` credita o caixa de forma incremental a cada
venda. No fechamento, `CaixaService::atualizar()` **recalcula tudo do zero** a
partir dos pagamentos e das retiradas, descartando o valor acumulado.

As duas divergem quando há troco devolvido em espécie: o acúmulo incremental
soma o valor líquido (`recebido − troco`), enquanto a reconciliação soma o valor
cheio dos pagamentos. O troco não é descontado — aparece à parte no formulário
de fechamento, para conferência manual do operador.

Confirmado por cinco testes em `CaixaAberturaFechamentoTest`. Não é defeito: é
uma escolha de deixar a conferência do troco para a pessoa. Mas a migração
precisa reproduzir as duas contas, e não apenas uma.

### Cupons: um por venda, sem repetição

`gerarCupons()` percorre os cupons disponíveis da unidade e **interrompe no
primeiro elegível** (`break`), então cada fechamento entrega no máximo um cupom,
mesmo havendo outros válidos. Um cupom nunca é reofertado a quem já o possui, e
a validade é contada em dias a partir do dia do fechamento.

As duas regras são `nao_consumiu` (para quem nunca comprou o produto-regra) e
`consumiu` (para quem comprou o produto-regra no mesmo dia). Confirmado por cinco
testes em `CupomTest`.

### Convênio: desconto na inclusão do item, só em serviço e por dia da semana

O desconto de convênio é aplicado ao **adicionar o item à comanda**, não no
fechamento — mesma característica já registrada para a comissão. Vale apenas
para itens do tipo `ser`: produto de barbearia e empório não recebem desconto,
mesmo com o cliente conveniado.

Três condições precisam valer ao mesmo tempo: o convênio estar ativo, o item ser
serviço e a **unidade permitir convênio no dia da semana atual** (colunas
`convenio_segunda` a `convenio_domingo`). Confirmado por cinco testes em
`ConvenioDescontoTest`.

Há ainda uma regra implícita em `ConvenioUnidadeRepository::convenioAtivo`: se o
convênio **não tem vínculo com nenhuma unidade**, vale para todas; se tem
vínculos, vale só nas vinculadas. É o tipo de semântica que uma reescrita
inverte sem perceber.

### Descontos acumulam, e o percentual do PDV compõe sobre o do convênio

O desconto manual do PDV soma ao do convênio no mesmo campo `valor_desconto`.
Quando informado em porcentagem, incide sobre o valor **já reduzido** pelo
convênio: 10% de PDV sobre um item de 89,90 com convênio de 20% resulta em 64,73,
e não nos 71,92 − 8,99 que a leitura ingênua sugere.

### Desconto maior que o total é descartado em silêncio

A guarda `if ($descontoPdv <= $vendaProduto->getValorTotal())` simplesmente não
aplica o desconto quando ele excede o valor do item. Não há erro, aviso nem
registro: o item fica com o valor cheio e quem digitou não é informado.

### Sangria pode deixar o caixa negativo

A validação que deveria impedir retirada acima do saldo **nunca executa**, por um
`empty()` que deveria ser `!empty()`. Um caixa com 100,00 aceita retirada de
250,00 e fica em −150,00. Confirmado por
`CaixaTest::testRetiradaMaiorQueOSaldoDeixaOCaixaNegativo`.

É um **defeito**, não decisão de projeto. Ainda assim não foi corrigido: ver
débito D20 para o motivo e para a verificação que precisa ser feita em produção
antes.

---

## Os defeitos deste documento

A maior parte do que está registrado aqui é decisão de projeto — discutível, mas
deliberada. Dois itens não são:

| Item | Efeito | Débito |
|---|---|---|
| Sangria não valida saldo | O caixa pode ficar negativo | D20 |
| Lava VIP gravado cruzado nos royalties | Relatório de franquias mostra faturamento e retenção trocados; a cobrança não é afetada | D22 |

Nenhum dos dois foi corrigido, e é proposital: corrigir durante a caracterização
invalidaria a comparação entre o sistema atual e o novo. Os testes falham no dia
em que a correção for aplicada, e é esse o gatilho para revisar cada decisão.

O D22 não tem impacto financeiro (a cobrança usa outro caminho); o D20 pode
deixar caixa negativo e é o que merece atenção primeiro.

---

## A investigar (ainda sem teste)

Alvos previstos para a W4 e ainda não cobertos, em ordem de risco:

| Alvo | Onde | Situação |
|---|---|---|
| Fechamento de venda | `finalizar()` | ✅ coberto (`FechamentoVendaTest`) |
| Troco, fiado, estoque e caixa | `finalizar()` | ✅ coberto (`PagamentoTrocoDebitoTest`) |
| Desvio do cliente avulso | `finalizar()` | ✅ coberto (`ClienteAvulsoTest`) |
| Pagamento misto e rateio | `adicionar()`, `remover()` | ✅ coberto (`PagamentoMistoTest`) |
| Sangria de caixa | `CaixaService` | ✅ coberto (`CaixaTest`) |
| Fidelidade: acúmulo de pontos | `salvarPontuacao()` | ✅ coberto (`FidelidadePontuacaoTest`) |
| Fidelidade: resgate e vencimento | `ClienteService`, cron de vencimento | ✅ coberto (`FidelidadeResgateVencimentoTest`) |
| Fidelização por unidade e por colaborador | `finalizar()` | ✅ coberto (`FidelizacaoTest`) |
| Royalties | `FranquiaService::royaltiesAtualizar()` | ✅ coberto (`RoyaltiesTest`) |
| Abertura e fechamento de caixa | `CaixaService::abrir/fechar/atualizar` | ✅ coberto (`CaixaAberturaFechamentoTest`) |
| Cupons | `gerarCupons()` | ✅ coberto (`CupomTest`) |
| Convênios e desconto do PDV | `VendaProdutoService::insert()` | ✅ coberto (`ConvenioDescontoTest`) |

O arranjo de teste (`CaracterizacaoTestCase` + `fixtures.sql`) está pronto e os
cenários pendentes são trabalho incremental.

Dois deles dependem de dados que o cenário mínimo não tem: **fidelização por
unidade** precisa de várias vendas do mesmo cliente dentro da janela configurada,
e **royalties** ficam mais realistas sobre a base anonimizada da W2.

---

## Ids fixos no código — risco direto para a migração

O código compara identificadores de banco **literais**, o que faz a regra de
negócio depender de linhas específicas da tabela `formas_pagamentos`:

| Local | Comparação | Significado aparente |
|---|---|---|
| `VendaPagamentoService:64` | `totalPagamento($venda, 5)` | Forma de pagamento "crédito do cliente" |
| `VendaPagamentoService:65` | `totalPagamento($venda, 6)` | Forma de pagamento "crédito de fidelidade" |
| `VendaPagamentoService:359` | `getFormaPagamento()->getId() == 6` | Idem |
| `VendaPagamentoService:191` | `getCliente()->getId() != 1` | Cliente avulso ("balcão") |

O caso do cliente é o mais traiçoeiro dos quatro: o id 1 desliga um bloco inteiro
de regras — pontuação de fidelidade, pacotes, cupons, checkout da agenda,
sorteios e comissão de indicação —, e a venda continua fechando normalmente.
Caracterizado por `ClienteAvulsoTest`, com controle positivo (cliente comum
recebe pontuação) e negativo (o id 1 não recebe).

Se a carga no PostgreSQL reatribuir chaves e um cliente real herdar o id 1, ele
para de pontuar **sem nenhum erro** — e a diferença só apareceria quando alguém
reclamasse do saldo de pontos.

**Na migração para PostgreSQL, esses ids precisam ser preservados exatamente.**
Uma carga que reatribua chaves primárias — comum quando se usa sequences novas —
faria o sistema debitar crédito errado silenciosamente, sem nenhum erro. Ver
débito D18.

## O cabeçalho e o detalhe do NPS discordam sobre o `contabilizado`

**Encontrado em 12/09/2026**, escrevendo os testes de valor do D10. Dois métodos
do **mesmo arquivo**, usados pela **mesma tela**, olham as mesmas respostas e
filtram diferente:

| Método (`ISCadastro\Entity\NpsItemRepository`) | Filtra `nps.contabilizado`? |
|---|---|
| `selecionarTotalizadoresPorColaboradorMesAno` | **sim** — `AND nps.contabilizado = :contabilizado` |
| `colaboradorDetalhes` | **não** |

O primeiro alimenta a consolidação; o segundo alimenta o quadro que abre quando
alguém clica no número do colaborador, em
`PesquisasNpsController::colaboradorDetalhesAction`. Quem clica para conferir o
número encontra outra conta: o detalhe calcula a média sobre respostas que o
totalizador não contou.

Medido no cenário de caracterização: cabeçalho **2 avaliações, soma 18**; detalhe
**média sobre 3 respostas = 6,67**. Está gravado em
`PesquisasNpsNoRelatorioTest::testOCabecalhoEODetalheDiscordamSobreOContabilizado`,
que falsifica por construção — acrescentar o filtro que falta deixa o caso
vermelho.

**A divergência é transitória, e isso é o que a torna fácil de não ver.** Ela
existe na janela entre a pesquisa ser respondida e o cron consolidá-la:
`npsContabilizar` roda a cada 10 minutos (ver `f1-6-agendamento.md`), e depois
dele a pesquisa fica `contabilizado = true` e os dois métodos voltam a concordar.
Medido em 12/09/2026 na cópia local: **0 de 37.957** pesquisas estão pendentes,
ou seja, num retrato do banco a diferença não aparece — ela aparece para quem
abre a tela dentro da janela.

Não foi corrigida: é caracterização, e alinhar os dois filtros muda número que o
usuário vê. O que o registro garante é que, no dia em que alguém alinhar, o teste
avisa.

### E duas suspeitas vizinhas que NÃO sobreviveram à medição

Ficam escritas para ninguém refazer o caminho:

**1. A média anual do colaborador é média de médias.** O controller calcula, por
mês, `nota / numeroAvaliacoes`, e depois tira a média **simples** dos meses — um
mês com uma pesquisa pesa igual a um mês com cinquenta. A forma é a mesma da
média ponderada que já rendeu correção em vendas. Medido sobre
`nps_resultados_colaboradores`: de **1.427** pares colaborador/ano com mais de um
mês, **2** divergem da média ponderada, e a maior diferença é **0,024** numa
escala de 0 a 10. A tabela consolidada tem praticamente uma linha por
colaborador/mês, então a média mensal *é* a própria linha. Imaterial; não entra
como anomalia.

**2. A consolidação sobrescreve a linha a cada item da pesquisa.** Em
`CronJobService::npsContabilizar`, `setNota(...)` é chamado dentro do laço dos
itens, e o valor do último item sobrescreve os anteriores. Só morde quando uma
pesquisa tem **dois itens do mesmo colaborador**. Medido: **0** ocorrências em
37.957 pesquisas. Não alcançável pelo dado de hoje.

## A receita reportada à plataforma externa leva R$ 0,10 a mais quando é redonda

**Encontrado em 12/09/2026**, escrevendo o teste de `relatorioYungas`. Em
`CronJobService`, logo depois de calcular a receita da unidade no mês e pouco
antes de enviá-la:

```php
$data['revenue'] = round($repoVenda->relatorioYungas(...), 2);
// ...
if (!stripos($data['planned'], '.')) { $data['planned'] = $data['planned'] + 0.1; }
if (!stripos($data['revenue'], '.')) { $data['revenue'] = $data['revenue'] + 0.1; }
// ...
$ch = curl_init('https://app.yungas.com.br/api/finances/');
```

`stripos` devolve `false` quando não encontra o ponto, e `!false` é verdadeiro.
Então **toda receita que der número redondo sai R$ 0,10 maior** do que a medida —
e o mesmo vale para o `planned`, o potencial planejado da unidade.

**Para onde o número vai:** `https://app.yungas.com.br/api/finances/`, a
plataforma de gestão de franquias. É a receita que a franqueadora **reporta a
terceiro**, por unidade e por mês. Um erro aqui não aparece em tela nenhuma do
ERP; aparece no painel de outra gente.

**O que provavelmente se quis fazer:** garantir que o JSON levasse um decimal,
para que do outro lado o valor fosse lido como número fracionário e não como
inteiro. Há duas formas honestas de conseguir isso — `number_format($v, 2)` ou
converter com casas fixas —, e nenhuma delas soma dez centavos ao que se afirma.

**O tamanho:** dez centavos por unidade e por mês, nas vezes em que a soma cair
redonda. Com 32 unidades ativas, o teto é R$ 3,20 por mês de receita
sobre-reportada, e o mesmo no planejado. É pequeno em reais e não é pequeno em
natureza: **é um número enviado para fora que o sistema sabe estar errado.**

Não foi corrigido: a rotina é cron, o consumidor é externo, e mudar o que se
reporta a terceiro é decisão de quem opera — além de que o **D4** registra que 27
das 30 ações de cron respondem sucesso de qualquer maneira, então o caminho inteiro
precisa de atenção junto, e não de um remendo. O que existe hoje é este registro e
`ReceitaParaYungasTest`, que fixa o número **antes** do acréscimo.
