# F1.4 — Compatibilidade de consultas com o PostgreSQL

O débito **D24**. A produção roda com `ONLY_FULL_GROUP_BY` desligado; o
PostgreSQL não tem esse botão. O que quebrar aqui, quebra em produção depois do
corte.

Levantamento refeito em 26/08/2026.

---

## O número mudou: 33, não 4

A auditoria anterior classificava em três faixas — RISCO ALTO (4), REVISAR (29),
OK (38). A distinção descrevia a **forma** da consulta, não a consequência:

| | |
|---|---|
| RISCO ALTO | projeta a entidade inteira agrupando por outra coisa |
| REVISAR | projeta campos escalares fora do agrupamento |

**As duas quebram.** `planosCarreiras` projeta `das.estrela` e `uni.pais`
agrupando por `col.id`, aparecia como REVISAR — e o PostgreSQL a recusa
exatamente como recusa as quatro marcadas como risco alto.

E o `ORDER BY` não era analisado. É a metade esquecida da regra: `GROUP BY
col.id ORDER BY das.totalVendas` quebra mesmo com o SELECT inteiro correto.
Cinco consultas só aparecem por causa disso.

A auditoria agora aplica a regra que o PostgreSQL de fato usa, no SELECT, no
ORDER BY e no HAVING:

> A referência é aceita se a expressão estiver no `GROUP BY`, **ou** se o
> `GROUP BY` contiver a chave primária da tabela dela — a dependência funcional,
> reconhecida desde a versão 9.1. `GROUP BY pro.id` libera qualquer `pro.campo`,
> e não libera `uni.campo`.

---

## Duas naturezas de quebra, e só uma tem conserto mecânico

A ferramenta lê as associações declaradas nas entidades e separa:

**Mecânica.** O alias ofendido é alcançável a partir do agrupado seguindo só
associações de **valor único**. Cada linha do grupo tem um único registro do
outro lado, então acrescentar a chave primária dele ao `GROUP BY` **não muda
resultado nenhum** — só informa ao PostgreSQL o que o MySQL já assumia.

`GROUP BY ven.id` projetando `cli.nome`: cada venda tem um cliente. Agrupar por
`(ven.id, cli.id)` é o mesmo conjunto de grupos.

**Decisão.** O alias vem por uma associação de **valor múltiplo**. Há vários
candidatos, e o MySQL escolhia um arbitrariamente. Não existe conserto que
preserve o comportamento, porque **o comportamento atual não é bem definido** —
o MySQL nem promete devolver sempre a mesma linha.

> Nota sobre o mapeamento: o projeto declara `OneToOne` onde a cardinalidade
> real é muitos-para-um — várias vendas para o mesmo cliente. Para esta análise
> dá no mesmo: os dois garantem **um** registro do outro lado, que é a
> propriedade que importa. É imprecisão de modelagem, não erro de análise.

---

## A ressalva que muda a leitura do número

**A DQL é montada por concatenação, e a análise estática não distingue ramos
mutuamente exclusivos.** Quando um método monta uma consulta diferente por
caminho, a ferramenta vê os dois textos juntos e acusa referências que nunca
convivem na mesma execução.

`VendaRepository::listagemIndexRelatorioDescontos` é o exemplo: a ferramenta
aponta `pro.valorTotal` e `venpag.valor` como decisões, mas cada um vive num
ramo com o seu próprio agrupamento — `GROUP BY pro.id` e `GROUP BY venpag.id`,
ambos pela chave primária, ambos **corretos** no PostgreSQL.

O viés é deliberado: **acusa a mais, nunca a menos.** Mas significa que o número
de decisões é um teto, não um total. Cada uma precisa ser lida antes de virar
trabalho.

---

## Primeiro caso analisado por inteiro

### `VendaRepository::listagemIndexRelatorioDescontos` — relatório de descontos

Três ramos, e só o primeiro tem problema real:

| Ramo | Agrupamento | Veredito |
|---|---|---|
| Listagem | `GROUP BY ven.id` | **quebra**, e uma parte é decisão |
| Total por colaborador | `GROUP BY pro.id` | correto — chave primária |
| Total por forma de pagamento | `GROUP BY venpag.id` | correto — chave primária |

Na listagem:

- `cli.id`, `cli.nome`, `usu.nome`, `con.nome` — **mecânicas.** Cada venda tem um
  cliente, um usuário e no máximo um convênio. Entram no `GROUP BY`.
- `pro.descontoMotivo` — **decisão.** `pro` vem de `LEFT JOIN ven.produtos`, que
  é de valor múltiplo.

**A anomalia, que existe hoje e não foi criada pela migração:** uma venda com
dois produtos descontados tem dois motivos de desconto, e o relatório mostra
**um só, escolhido arbitrariamente pelo MySQL**. Não é erro de arredondamento
nem de soma — é informação faltando numa tela que serve para auditar desconto.

As saídas possíveis, para decidir:

1. **Uma linha por produto descontado.** O relatório passa a mostrar todos os
   motivos, e o número de linhas cresce. É o que a tela provavelmente sempre quis.
2. **Concatenar os motivos** com `STRING_AGG`. Mantém uma linha por venda e não
   perde informação.
3. **Manter uma linha por venda com um motivo qualquer**, agora explícito —
   `MIN(pro.descontoMotivo)`. Preserva a aparência de hoje e torna a escolha
   determinística, em vez de arbitrária.

A opção 3 é a única que não muda o que a tela mostra. As opções 1 e 2 corrigem
uma perda de informação, e por isso são mudança de produto, não de migração.

---

## As vendas, analisadas em 26/08/2026

### O que a ferramenta aprendeu no caminho

Três cegueiras apareceram ao ler estas consultas, e cada uma inflava a contagem:

**Não removia comentários PHP.** Um comentário que mencione `GROUP BY` virava
consulta fantasma — foi acusada a própria explicação de um conserto anterior.

**Só registrava a aresta no sentido declarado da junção.** `JOIN ven.produtos
venpro` declara o sentido de valor múltiplo, mas o inverso é de valor único:
cada item pertence a uma venda só. É esse o sentido que importa ao agrupar pelo
lado de baixo. Sem ele, `listagemIndexVendasExportarXls` aparecia com vinte
campos como decisão, sendo todos dependência funcional legítima.

**Não lia o `WHERE`.** `WHERE venpro.colaborador = :colaborador` garante um
colaborador no resultado inteiro, então `col.comissaoProduto` não tem como ser
ambíguo. Isso **não torna a consulta válida** — o PostgreSQL não faz a
inferência e a coluna continua tendo de entrar no `GROUP BY`. O que a informação
decide é se acrescentá-la **preserva** o resultado, e preserva.

### A limitação da subconsulta, fechada em 26/08/2026

A extração da projeção parava no **primeiro** `FROM`, e uma subconsulta na lista
de campos tem `FROM` próprio: em `fidelizacaoPorColaborador` a análise parava
dentro da subconsulta e não examinava o resto. A ferramenta acusava **a menos**,
que é o oposto do seu viés declarado.

Agora a projeção é extraída contando parênteses — um `FROM` de subconsulta está
sempre em profundidade positiva. E o escopo é respeitado: `ven2.status` não
responde ao `GROUP BY` de fora, porque `ven2` nasce no `FROM` da subconsulta; só
as referências a aliases **de fora** usadas lá dentro continuam sendo examinadas.

**Duas coisas apareceram ao fechar isso, e valem mais que a correção.**

A primeira: a contagem caiu de 67 consultas para 66. Uma tinha **sumido da
análise** — `PrivilegioRepository::selecionarPorGrupo`, que monta a DQL com um
ternário:

```php
$dql = (!$obj ? "SELECT pri.id, ..." : "SELECT pri") . " FROM ..."
```

O `SELECT` nasce dentro de parênteses. A contagem começava em zero, encontrava o
fecha-parênteses do ternário e descia para −1, então o `FROM` externo nunca era
alcançado. Comparar com `<= 0` resolve.

A segunda, e é a que fica: **não dar conta de uma consulta não é o mesmo que
aprová-la.** A ferramenta agora lista o que não conseguiu ler, em separado das
quebras, com o aviso de que estar fora da análise é pior que estar na lista. Sem
isso, o portão poderia ficar verde por ignorância — e foi por pouco.

**Cobertura hoje: 67 consultas lidas, 0 fora da análise.**

### O relatório de comissões

`VendaProdutoRepository::selecionarVendasProdutosPorColaboradorPeriodo` agrupa
por `pro.id` e mostra `venpro.valorUnitario` ao lado de `SUM(quantidade)` e
`SUM(valorTotal)`.

#### O que causa a variação, apurado com dados

Duas hipóteses levantadas pelo responsável foram testadas e **as duas caíram**:

**Não é desconto.** Nas 85.235 linhas com desconto desde 2025,
`valor_total = quantidade × valor_unitario − valor_desconto` vale em **todas**.
Ou seja, `valor_unitario` é o **preço base**, e desconto de convênio ou
esporádico é registrado à parte, sem tocá-lo.

**Não é preço regional.** A consulta já filtra `usu.unidade = :unidade`, e
agrupar por unidade não subdividiu grupo nenhum — cada colaborador está numa
unidade só. O relatório já é de uma região.

**É pacote.** O mesmo serviço vale diferente dentro de um pacote, porque o preço
negociado do pacote é rateado entre os itens. Dos 1.269 grupos com variação, 886
misturavam item de pacote com avulso.

#### Mas o relatório já separa pacote de avulso

Os quatro chamadores em `VendasController` passam `'nao'` ou `'sim'` — **nunca
vazio**. A mistura que explica 70% da variação nunca acontece na tela. Medindo
no escopo real:

| Relatório | Grupos | Preço variando | Maior diferença |
|---|---:|---:|---:|
| **Avulso** (`'nao'`) | 44.956 | **380 — 0,85%** | R$ 25 |
| **Pacote** (`'sim'`) | 929 | **380 — 40,9%** | R$ 44 |

São dois problemas de tamanhos diferentes, e é isso que decide o conserto.

No **avulso**, a ambiguidade é resíduo: 0,85%, e quando aparece é **reajuste de
preço**, confirmado com dados em 26/08/2026.

A hipótese foi levantada pelo responsável e tem assinatura verificável: num
reajuste, todas as vendas ao preço antigo vêm ANTES de uma data e todas ao novo
DEPOIS — os intervalos não se cruzam. Num ajuste manual de balcão, os dois preços
conviveriam.

**Dos 296 grupos com preço variando em 2025, 296 têm transição limpa. Cem por
cento.** E a distribuição por mês acompanha: novembro tem 88 grupos contra 4 a 12
no meio do ano.

Dois exemplos, com as datas à vista:

| Unidade | Produto | Preço | Período |
|---|---|---|---|
| 38 | Barba | R$ 55 | 01/11 → **15/11** |
| 38 | Barba | R$ 70 | **17/11** → 29/11 |
| 5 | Corte Adulto + Barba | R$ 120 | até **20/12** |
| 5 | Corte Adulto + Barba | R$ 135 | a partir de **22/12** |

O que o relatório mostrava contra o valor real:

| Produto | Mostrava | Real |
|---|---|---|
| Barba, 68 itens | R$ 3.740 **ou** R$ 4.760 | **R$ 4.220** |
| Corte Adulto + Barba, 55 itens | R$ 6.600 **ou** R$ 7.425 | **R$ 6.975** |

**Isto reforça a média ponderada, e por um motivo que não existia antes:** no mês
do reajuste, **nenhum dos dois preços representa o mês**. R$ 55 e R$ 70 estão os
dois corretos, cada um no seu período. O que descreve o mês é o preço médio
efetivamente cobrado — e é ele que fecha com o total.

O `MAX`, oferecido como alternativa mais simples, seria **pior**: mostraria sempre
o preço novo e inflaria o total do mês do reajuste. No **pacote**, é estrutural — em 41% dos casos o rateio
dá valores diferentes para o mesmo item, e mostrar um deles não significa nada.

#### As saídas

1. **`MAX(venpro.valorUnitario)`.** Determinístico, continua sendo um preço que
   existiu. Resolve bem o avulso; no pacote, escolhe o maior rateio sem dizer que
   escolheu.
2. **`SUM(venpro.quantidade * venpro.valorUnitario) / SUM(venpro.quantidade)`** —
   a média ponderada do preço base. **Preserva o significado da coluna** e fecha
   com a quantidade exibida ao lado.
3. **Tirar a coluna do relatório de pacote**, onde ela é estruturalmente
   ambígua, e usar `MAX` no avulso.

**Cuidado com uma armadilha:** `SUM(valorTotal) / SUM(quantidade)` — que parece a
opção natural — dá o preço **líquido**, porque `valorTotal` já desconta. Trocaria
o significado da coluna em silêncio, de preço base para preço praticado.

A opção 2 é a única que elimina a inconsistência; muda o número exibido nos 2,8%.

### A pesquisa de NPS

`pesquisaNpsLocalizarColaboradores` agrupa por `col.id` e devolve `ven.id` — uma
venda **arbitrária** entre as recentes daquele cliente com aquele colaborador. A
pesquisa de satisfação fica amarrada a uma visita qualquer.

`MAX(ven.id)` resolve, e é a leitura natural: a pesquisa se refere à **última**
visita.

### Fidelização e evasão por colaborador

`fidelizacaoPorColaborador` e `evasaoPorColaborador` agrupam por `cli.id` e
projetam `ven.id AS vendaId` **ao lado de** `DATE(MAX(ven.dataCriacao)) AS
ultimaVenda`.

Repare no contraste: a data já é agregada com `MAX`, o identificador não é.
**As duas colunas podem se referir a vendas diferentes** — a data é da última
venda, o id é de qualquer uma. Já é incoerência hoje, com o MySQL.

`MAX(ven.id)` alinha as duas, já que o id cresce com o tempo.

Em `fidelizacaoPorColaborador` há uma complicação: a subconsulta correlacionada
usa `ven.id` no `WHERE` (`ven2.id > ven.id`), e essa referência precisa do mesmo
tratamento. É a consulta mais delicada do conjunto e a única que não deve ser
mexida sem teste próprio.

> **25/10/2026:** a `fidelizacaoPorColaborador` saiu do código (`VendaRepository`), com a
> rotina das 03:10 que era a única a chamá-la (`relatoriosFidelizacaoAtualizar`; a resposta
> 138 (a) do dono, o D306 e a fatia 41 da fila das telas, FIDELIZACAO-SAI). O que esta
> seção diz dela é histórico; a `evasaoPorColaborador` continua, e o que vale para ela não
> mudou. O mesmo vale para a menção a ela na seção da limpeza da subconsulta, acima.
>
> **04/11/2026:** a `evasaoPorColaborador` (e a `evasaoPorColaboradorTotalVendas`) saiu do código também, com a rotina
> das 04:10 que era a única a chamá-la (`relatoriosEvasaoAtualizar`) e a tela antiga da Evasão (a fatia 76d da fila das
> telas, CARTEIRA-CA3; a resposta 138 (a) do dono, o D691). O que esta seção diz das duas é histórico.

### As listagens de venda

`listagemIndexRelatorioDescontos`, `listagemIndexVendas`,
`listagemIndexVendasExportarXls` e `listagemIndexCanceladas` agrupam por
`ven.id` e mostram campos de **um item ou pagamento arbitrário** da venda:
`pro.descontoMotivo`, `pro.valorTotal`, `pro.quantidade`, `col.nome`,
`pag.nome`, `venpag.valor`.

É a mesma decisão do relatório de descontos, repetida: uma venda com dois
produtos ou dois pagamentos mostra um só, escolhido pelo banco. Vale decidir uma
vez e aplicar às quatro.

---

## As campanhas por telefone — a decisão que não tem conserto mecânico

Três consultas disparadas por cron agrupam por telefone:

| Método | Campanha |
|---|---|
| `selecionarAniversariantes` | parabéns no dia |
| `selecionarClientesNovos` | pesquisa de satisfação do cliente novo |
| `selecionarPorDataUltimaVisita` | "sentimos sua falta", 30 dias sem visitar |

A intenção é evidente e correta: **não mandar duas mensagens à mesma pessoa**
quando ela está cadastrada em mais de uma unidade.

**Aqui não existe conserto mecânico.** Acrescentar `cl.id` ao agrupamento faria
cada cliente virar seu próprio grupo e destruiria a deduplicação — exatamente o
que a consulta existe para fazer. O `GROUP BY` **é** a regra de negócio.

### O que a escolha decide, e não é pouco

O que vai junto na projeção não é só o nome: são as credenciais de WhatsApp da
unidade (`un.whatsappApiKey`, `un.whatsappInstancia`, `un.whatsappProvedor`) e as
preferências de mensageria dela.

Ou seja, ao escolher a linha o banco está decidindo **qual unidade manda a
mensagem** — hoje, arbitrariamente. Se a pessoa é cliente de Curitiba e de
Itajaí, quem dá os parabéns é sorteio.

### O que não dá para medir aqui

A anonimização deriva o telefone de `MOD(id, 10000)`, então na base de
desenvolvimento os números **colidem por construção**. Medir duplicidade de
telefone aqui daria um número inventado — é a mesma armadilha registrada em
`ambiente-desenvolvimento.md`, e ela já pegou este projeto antes.

`scripts/medir-telefones-compartilhados.sql` existe para rodar **na réplica do
BVIP**, que tem dado real. É só leitura, e responde: quantos telefones são
compartilhados, quantos atravessam unidades, quais são os mais repetidos (o D32,
telefone de balcão) e **quantas pessoas deixam de receber parabéns por ano**.

### As saídas

1. **Linha mais antiga por telefone** — `MIN(cl.id)`. Determinístico e estável:
   quem cadastrou primeiro manda a mensagem, sempre a mesma unidade.
2. **Unidade da última visita.** Mais relevante para o cliente, e já é o que
   `selecionarPorDataUltimaVisita` usa na junção — só a escolha da linha é que
   ainda é aleatória.
3. **Parar de deduplicar por telefone** e deduplicar por pessoa, quando a tabela
   `pessoas` da F1.2 existir. É a resposta certa a longo prazo: telefone é chave
   de identidade provisória, e a F1.2 já registrou o porquê.

Em DQL, qualquer das duas primeiras se expressa assim, sem `GROUP BY` externo:

```sql
WHERE cl.id IN (
    SELECT MIN(c2.id) FROM Cliente c2
     WHERE <mesmos filtros> GROUP BY c2.telefoneSemMascara
)
```

Explícito, determinístico, e diz na cara qual linha representa o telefone.

### Um detalhe menor, mas anote

`selecionarAniversariantes` monta o dia e o mês por concatenação de string em vez
de parâmetro. Os valores vêm de `date('d')` e `date('m')` no cron, então não há
entrada de usuário no caminho e não é vulnerabilidade hoje. É prática ruim que
sobrevive por sorte — vale trocar por parâmetro quando a consulta for mexida.

---

## As 11 que restam, e o que proponho para cada uma

Levantamento fechado em 26/08/2026. Nenhuma tem conserto mecânico — todas
escolhem uma linha entre várias, e o MySQL escolhia sozinho.

### O que segue sem perguntar, e por quê

**`PrivilegioRepository::selecionarTodos` — remover.** Não tem chamador nenhum.
Consertar consulta que ninguém executa é escrever código para um caminho
inexistente.

**`NpsItemRepository::colaboradorDetalhes` — `AVG(item.nota)`.** A coluna já se
chama `notaMedia` e hoje mostra **uma nota**, escolhida entre as respostas do
cliente. A média cumpre o nome, e quando há uma resposta só — o caso comum — dá
exatamente o mesmo valor de hoje. O `ORDER BY nps.notaMedia` acompanha com
`AVG`.

**`ClienteRepository::listagemIndexClientesSemRetorno` — reescrever sem `GROUP
BY`**, selecionando a última venda por subconsulta:

```sql
AND ven.id = (SELECT MAX(v2.id) FROM Venda v2 WHERE v2.cliente = cli.id AND v2.status = 1)
```

Isto corrige um defeito real, não só de compatibilidade. A tela
`clientes-sem-retorno.phtml` usa `msgRelSemRetorno` para colorir o botão do
WhatsApp e `vendaId` como alvo do link. Com `GROUP BY cli.id` e as duas colunas
fora do agrupamento, **elas podem vir de vendas diferentes** — o botão diz
"mensagem já enviada" olhando uma venda e o clique marca outra.

**As partes mecânicas das quatro listagens de venda.** São as chaves que faltam
no agrupamento; não mudam resultado.

### Decisão 1 — as quatro listagens de venda

`listagemIndexRelatorioDescontos`, `listagemIndexVendas`,
`listagemIndexVendasExportarXls` e `selecionarVendasPorConvenio` mostram uma
linha por venda com campos de **um item ou pagamento**, escolhido pelo banco.

| Campo | Vem de | Aparece em |
|---|---|---|
| `pro.descontoMotivo` | itens da venda | descontos, vendas |
| `pro.quantidade`, `pro.valorTotal` | itens da venda | vendas, descontos |
| `col.nome` | colaborador do item | vendas, convênio |
| `pag.nome`, `venpag.valor` | pagamentos da venda | vendas, exportação, convênio |

**Para os números, proponho `SUM` sem submeter a decisão:** numa linha por
venda, a soma dos itens é o que o leitor espera, e os pagamentos somados dão o
total pago. Mostrar o valor de um item avulso ao lado do total da venda não
descreve nada.

**Para os textos, a decisão é sua:**

| | O que muda |
|---|---|
| **`MIN(...)`** | Mostra um, agora sempre o mesmo. Tela idêntica à de hoje |
| **Concatenar os distintos** | "Dinheiro, Cartão" em vez de um só. Não perde informação; exige função DQL nova |
| **Uma linha por item** | Nada se perde e nada se resume. A venda aparece repetida e a listagem cresce |

### Decisão 2 — as campanhas por telefone

Três consultas escolhem qual cadastro representa o número, e a escolha decide
**qual unidade dispara a mensagem** — as credenciais de WhatsApp vão na mesma
projeção. Atinge os 1.642 telefones que atravessam unidades.

| | |
|---|---|
| **Cadastro mais antigo** (`MIN(cl.id)`) | Estável: a mesma unidade sempre dispara |
| **Unidade da última visita** | Mais relevante para o cliente; muda com o tempo |
| **Só tornar determinístico agora** | E resolver de vez com a tabela `pessoas` da F1.2 |

Lembrando o que já foi medido: a "perda de 3.473 parabéns" era ruído de
`data_nascimento` inventada, não gente sem mensagem. A urgência aqui é menor do
que parecia.

### Decisão 3 — a duplicata de privilégio

`PrivilegioRepository::selecionarPorGrupo` agrupa por funcionalidade para não
repetir linha na tela de permissões. Há **uma** duplicata em 140 registros:

| id | grupo | funcionalidade | ler | escrever |
|---|---|---|---|---|
| 71 | 3 — Gerente unidade | 39 — Yungas | 1 | 1 |
| 105 | 3 — Gerente unidade | 39 — Yungas | 1 | 1 |

Idênticas. Apagando uma, a consulta fica trivialmente correta com
`GROUP BY pri.id, fun.id`, sem escolher nada. Sem apagar, é preciso decidir qual
das duas sobrevive dentro da consulta — trabalho para esconder uma linha
repetida.

**Proposta:** apagar a de id 105 na limpeza pré-migração, e mais uma chave única
em `(inquilino_id, grupo, funcionalidade)` para não voltar.

---

## O portão, e a supressão que ele exige

Três consultas ficaram **corretas e continuaram sendo acusadas**. A análise
estática lê o texto, não o caminho de execução: quando um método monta consultas
diferentes por ramo, ela junta os dois e reclama de referências que nunca
convivem numa execução.

Isso é um impasse de verdade. Sem saída, o portão nunca fecha; com saída
silenciosa, o portão vira enfeite.

A saída é uma marcação no corpo do método:

```php
// @agrupamento-conferido: <por que esta correta>
// <e as linhas seguintes, ate uma linha em BRANCO>
```

Com três regras que a tornam honesta:

| | |
|---|---|
| **Justificativa obrigatória** | menos de 40 caracteres não suprime — continua contando como quebra |
| **Sempre visível** | as suprimidas aparecem em seção própria, com o motivo, mesmo sem `--detalhe` |
| **Nunca misturada** | não entram na contagem de quebras nem no resumo de mecânicas |

Provado antes de usar: uma marcação com o motivo `ok` e outra sem motivo nenhum
**continuaram contando como quebra**.

E há uma quarta regra que a ferramenta não pode impor, então está escrita na
saída: *a marcação continua valendo depois de a consulta ser reescrita*. Quem
mexer numa consulta suprimida precisa reler o motivo.

### As três, e o que foi verificado em cada uma

| Consulta | Por que está correta |
|---|---|
| `listagemIndexRelatorioDescontos` | a listagem é enraizada no item e não agrupa; os `GROUP BY` vistos vêm dos três ramos de total, cada um pela chave da própria raiz |
| `listagemIndexVendas` | os campos de valor múltiplo estão em `LISTAR_DISTINTOS`; o que a ferramenta acusa vem dos ramos de total |
| `listagemIndexVendasExportarXls` | com `removerPagamento` as colunas saem da projeção; sem ele não há `GROUP BY` |

Nenhuma foi suprimida por leitura: **as três foram executadas contra a base com
`ONLY_FULL_GROUP_BY` ligado**, e os números conferidos — 20 linhas por página e
4.548 itens no relatório de descontos, uma venda com dois colaboradores em uma
linha na listagem, e 800/800/547 nas três abas da exportação.

---

## Portão de saída: ATINGIDO em 27/08/2026
### A ressalva do portão, dita com precisão

O portão pedia "as duas suítes verdes com o modo estrito ligado". A de
caracterização está: **67 testes, 128 asserções**. A de contrato tem **4 falhas**,
e elas não medem a F1.4.

A prova: rodei a suíte de contrato com o `ONLY_FULL_GROUP_BY` **ligado e
desligado**, e falham exatamente as mesmas quatro —
`testUnidadeSelecionar`, `testUnidadeSelecionarV2`, `testServicosSelecionar` e
`testBannersSelecionar`.

São instantâneos gravados com fixtures, comparados contra a base real anonimizada
carregada aqui: o endpoint devolve **mais** dados do que o instantâneo esperava,
que é o oposto de uma quebra. Está descrito em `ambiente-desenvolvimento.md`, e o
CI roda com fixtures, onde elas passam.

**Dizer "as duas suítes verdes" seria falso.** O que é verdade, e é o que o portão
queria medir: nenhuma consulta quebra com o modo estrito ligado, e as quatro
falhas que restam falham igual sem ele.


| | |
|---|---|
| Quebras no início | **33** |
| Quebras agora | **0** |
| Suprimidas, com motivo escrito | 3 |
| Consultas fora da análise | **0** |
| Código de saída da ferramenta | 0 |

De 33 para zero, e o caminho não foi só consertar consulta:

- **9 mecânicas** — chaves acrescentadas ao agrupamento, sem mudar resultado
- **7 removidas** — código morto, colunas que ninguém renderiza, junções que só
  alimentavam colunas mortas
- **14 decisões** — levadas ao responsável e implementadas
- **3 suprimidas** — corretas, com justificativa escrita e verificação por execução

E metade do trabalho foi na própria ferramenta: ela começou dizendo 4 quebras,
subiu para 33 ao passar a aplicar a regra do PostgreSQL, e ao longo do caminho
teve **seis defeitos corrigidos** — comentários lidos como SQL, aresta de junção
só num sentido, `WHERE` ignorado, subconsulta sequestrando as cláusulas, consulta
sumindo da análise sem aviso, e agregação do próprio projeto desconhecida.

---

## Como rodar

```bash
php scripts/auditar-group-by.php
php scripts/auditar-group-by.php --detalhe
```

Sai com código 1 se houver qualquer quebra, para servir de portão.

---

## Portão de saída

`auditar-group-by.php` sem quebras, e as duas suítes verdes com
`ONLY_FULL_GROUP_BY` ligado no MySQL — que é o detector barato antes de haver
PostgreSQL servindo a aplicação.

**Situação em 26/08/2026:** de 33 para **24**.

| | |
|---|---|
| Mecânicas aplicadas | 9, em 8 arquivos |
| Quebra corrigida em execução | 1 (`selecionarProdutoVenda`) |
| Restantes, todas exigindo decisão | 24 (teto — ver a ressalva dos ramos) |
| Caracterização com `ONLY_FULL_GROUP_BY` | **67 verdes, 128 asserções** |

O portão continua fechado pelas 24.

---

## O que o modo estrito pegou que a análise estática não pegaria

Ligado o `ONLY_FULL_GROUP_BY` no MySQL, cinco testes de caracterização caíram na
mesma consulta: `VendaProdutoRepository::selecionarProdutoVenda`, que faz
`SELECT venpro ... GROUP BY pro.id`.

O `WHERE` já filtra `pro.id = :produto`, então havia sempre **um** grupo e o
MySQL devolvia dele uma linha arbitrária. Os dois chamadores, em
`VendaPagamentoService`, só perguntam `empty()`/`!empty()` — é teste de
existência, e o conteúdo da linha nunca é lido.

`setMaxResults(1)` reproduz exatamente o que se observava: uma linha quando
existe alguma, nenhuma quando não existe. **Foi decisão só na aparência** — ler
os chamadores transformou o que a ferramenta marcou como decisão num conserto
óbvio. É o argumento para ler as 24 antes de tratá-las como trabalho.

## Duas armadilhas de ambiente, resolvidas no caminho

**A suíte de contrato apontava para `localhost`.** Ela roda dentro do contêiner
`app`, onde `localhost` é o próprio php-fpm, que não atende HTTP: as 28 respostas
voltavam com status 0 e a suíte parecia inteira quebrada. O padrão passou a ser
`http://nginx`.

**Quatro falhas de contrato sobraram, e não são da F1.4.** Provado desligando o
modo estrito: as mesmas quatro falham dos dois jeitos. São instantâneos gravados
com fixtures, comparados contra a base real anonimizada — o endpoint devolve
*mais* dados do que o instantâneo esperava, o oposto de uma quebra. Está descrito
em `ambiente-desenvolvimento.md`.
