# Fase 2 — a décima primeira fatia: as vendas realizadas

Escolhida em 08/09/2026. `/adm/api/vendas` existia desde a primeira fatia, e
sabia fazer **uma coisa só**: cancelar. Dez fatias construídas, 62 endpoints, e
não havia como **listar** nem **ver** uma venda.

O PDV cria a venda, o caixa a soma, o cliente a acumula em histórico — e a tela
que mostra o que foi vendido era a única de todas que não tinha recurso.

---

## O recorte

| endpoint | verbo | origem |
|---|---|---|
| `/adm/api/vendas` | `GET` | `indexVendasAction` |
| `/adm/api/vendas/:id` | `GET` | `detalhesAction` |
| `/adm/api/vendas/:id/cancelamento` | `POST` | já existia |

**Fica de fora:**

| o quê | por quê |
|---|---|
| `indexVendasExportarXls` | exportação em três abas — item × pagamento, por item, por pagamento; é arquivo, não recurso |
| `indexComandas` | a comanda aberta já tem `/adm/api/comandas` |
| `comandaAbrir`, `comandaAbrirAgenda`, `comanda` | abertura de comanda; pertencem ao recurso de comandas |
| `novo` | a venda nasce do fechamento da comanda, e não de um `POST` em `/vendas` |

---

## O recurso delega a `ComandaJson`, e essa é a decisão principal

Comanda e venda são a **mesma linha da mesma tabela** — `vendas.status` separa
as duas (D51). Se `VendaJson` montasse o próprio arranjo, a mesma venda teria
dois formatos conforme o endereço por onde fosse pedida, e a tela que navegasse
de um para o outro precisaria de duas leituras.

Então `completo()` chama `ComandaJson::completo()` e **acrescenta** o que só
interessa depois de fechada: convênio, caixa, NPS, troco, motivo do
cancelamento. Itens, pagamentos, fiado e as referências vêm de lá, com os mesmos
nomes.

Para isso o de-para de estado deixou de ser privado: `ComandaJson::nomeDoEstado()`
é público, e recebe o **valor** — porque a listagem de vendas agrupa e devolve
arranjo, não entidade. Duas cópias do mesmo de-para divergiriam na primeira vez
que alguém acrescentasse um estado de um lado só.

---

## Um estado, um nome — e a tela do ERP usa dois

`status = 1` é **"concluída"** na tela de comandas e **"ativa"** na tela de
vendas realizadas. É o mesmo valor na mesma coluna, e o vocabulário duplo é da
**interface**, não do dado.

A API fica com um nome só. O filtro da listagem continua aceitando o vocabulário
da tela (`ativas`/`canceladas`), porque é o que quem migra a tela vai mandar —
mas o que volta em `estado` é sempre o nome único.

---

## Duas regras da tela são reproduzidas, e não por capricho

A tela de vendas realizadas não é só uma consulta: ela **restringe quem vê o
quê**. Uma API que ignorasse isso seria o caminho para contornar a restrição.

**1. Quem é do caixa só vê hoje, e só as concluídas.** Na tela, três campos
ficam `disabled` e o controlador **reescreve** os valores. Desabilitar campo em
HTML não impede ninguém de mandar outra coisa — a reescrita é que vale. Aqui a
reescrita é a mesma.

*Mudou em 29/10/2026 (a resposta 271 (c) do dono, o D639, a fatia 57b):* só o
caixa que não é gerente nem administrador fica em hoje; com a marca de gerente, a
de caixa não trava, nas duas telas, pela mesma regra dos relatórios
(`RecorteDoRelatorio::soDoCaixa`). O administrador vai junto por ser a mesma regra
(o passo 1 dela), e não pela letra da resposta.

**2. Quem não é administrador só vê a própria unidade.** O parâmetro `unidade`
só é olhado para administrador.

E no detalhe, a unidade que manda é a **da venda**, não a da sessão — uma venda
antiga continua pertencendo à unidade onde foi feita. Quando não confere, a
resposta é `404`, e não `403`: dizer "sem permissão" informaria a quem perguntou
que aquela venda **existe** em outra unidade.

---

## Colaborador e forma de pagamento saem como lista

```sql
LISTAR_DISTINTOS(col.nome) AS colaborador_nome
```

**6,1% das vendas de 2025 têm itens de mais de um colaborador**, até sete numa
venda, e 1,8% têm mais de um pagamento (medido em 26/08/2026). A consulta agrupa
por venda e lista os múltiplos em vez de escolher um.

O recurso devolve esses campos como **lista**, e não como o texto colado que o
banco produziu: quem consome não deve precisar saber o separador. E vazio vira
`[]`, não `[""]` — venda só de produto de balcão existe, e um elemento em branco
faria a tela desenhar uma linha para ninguém.

---

## Os totais são da consulta inteira, e por isso não estão em `dados`

A tela mostra dois números no topo — soma dos valores e soma dos descontos — e
eles são de **toda** a consulta, não da página. Uma tela nova que os somasse a
partir de `dados` daria número menor a cada página virada.

`RespostaApi::colecao()` ganhou um quinto parâmetro opcional para isso. Ele entra
no envelope **ao lado** de `dados`, e as quatro chaves fixas são escritas por
último na união: um `extra` chamado `total` não pode redefinir a contagem de
linhas. Os oito chamadores existentes passam quatro argumentos e não mudam.

---

## O que esta API não faz, e a tela faz

```php
$unidadeDoNps = !empty($request['unidade']) ? $request['unidade'] : $this->unidadeId;

if (!empty($unidadeDoNps)) {
    $this->sm->get(CronJobService::class)->npsContabilizar($unidadeDoNps);
}
```

`indexVendasAction` chama isso **no meio da montagem da tela**. É uma **escrita
dentro de um GET**: a rotina persiste `NpsResultadoColaborador` e
`NpsResultadoUnidade`, com `flush()` dentro do laço.

Sempre UMA unidade, desde 06/10/2026 (o D428): a do filtro, ou, com ele em
branco, a da sessão. Até ali ia `npsContabilizar($request['unidade'])`, a unidade
do filtro tal como veio, e o administrador com ela em branco consolidava as
pendentes da rede inteira dentro do pedido dele.

A rotina **já roda de dez em dez minutos pelo cron** — está na tabela do
`f1-6-agendamento.md`, com limite de 180 s — e a cópia de produção tem **37.957
pesquisas com zero pendentes**. Na tela ela não encontra nada para fazer: o custo
real é uma consulta e um pedido de trava por carregamento.

A API não a chama. Leitura não escreve, e listagem não é lugar de disparar rotina
de fechamento. **A consequência está dita:** a nota de uma pesquisa respondida há
menos de dez minutos pode ainda não estar contabilizada — que é a defasagem do
cron, e não invenção desta fatia.

---

## Filtrar colaborador e pagamento juntos é 422, e não silêncio

A tela, quando recebe os dois, **limpa os dois campos** e mostra um aviso. O
motivo está na consulta: o total por colaborador agrupa por `pro.id` e o total
por forma de pagamento agrupa por `venpag.id`; os dois ramos não convivem numa
execução.

A API recusa com `422` nomeando os dois campos. Apagar em silêncio o que o
pedido mandou devolveria uma lista que não é a pedida, sem dizer que não é.

---

## O achado que virou uma linha só (D128)

```php
$venda->setStatus(true);   // ClienteService:95
```

`vendas.status` tem **quatro significados**: `null` é venda temporária —
`VendaService` escreve isso com essas palavras —, `3` é comanda aberta (D51), `0`
é cancelada e `1` é ativa. Não é um sinalizador.

`true` coagia para 1, que por acaso é o valor certo, e é só por isso que nunca
deu problema. Mas quem lê `setStatus(true)` numa coluna de quatro estados não tem
como saber qual deles foi escolhido — e o oposto, `false → 0`, significaria
**cancelada**.

A varredura: das **29 chamadas** `setStatus(true|false)` do `module/`, **quatro**
caem em coluna inteira. Três delas — pergunta e resposta de pesquisa, e VIP Class
— são colunas inteiras usadas com dois estados, e medidas: só contêm `1`. Lá o
booleano não esconde nada. Esta era a única em que escondia, e virou `setStatus(1)`.

---

## O que as hipóteses erradas custaram, e o que ensinaram

Vale registrar duas investigações que **não** deram em defeito, porque o caminho
até o "não" é o que impede de refazê-lo.

**43 vendas com `status = 3`, todas numa janela de quatro dias.** Parecia código
que entrou e saiu. Era o contrário: `3` é comanda aberta, a cópia de produção
termina em 16/08/2026 18:59, e as 43 são simplesmente as comandas que estavam
abertas no instante do retrato. Normal.

**16 vendas com `status IS NULL`, de 2019 a 2026, todas com item e nenhuma com
pagamento.** Parecia um caminho que criava venda sem estado — e o construtor de
`Venda` de fato nunca define `status`. Mas `VendaService:19` escreve
`setStatus(null); //null = venda temp`. O nulo é deliberado, e `ComandaJson` já
o nomeia `temporaria` desde a fatia do PDV. São vendas temporárias abandonadas.

Nos dois casos a medição chegou antes do anúncio. É a diferença entre um achado e
um alarme.

---

## E um que custou uma rodada vermelha

`vendas` mistura **três tipos** em cinco colunas com cara de sinalizador:
`comanda_temp` é `boolean`, `nps_notificacao`, `nps_respondido` e
`msg_rel_sem_retorno` são `smallint`, e `venda_credito` é `integer`.

Escrevi `TRUE` em `nps_respondido` na fixture. **O MySQL converteu e a suíte
passou verde; o PostgreSQL recusou:** *"column nps_respondido is of type smallint
but expression is of type boolean"*.

É o D128 inteiro numa linha — e as fixtures antigas já escreviam `0` ali. Só o
segundo banco pega.

---

## 26/09/2026 -- a tela de vendas realizadas, e o que a listagem passou a dizer

A quinta tela nova é a lista do que foi vendido, com o detalhe de cada venda. Só
leitura: cancelar fica para depois (o cancelamento subtrai do caixa da venda sem
olhar se ele fechou -- é pergunta para o dono antes de virar botão).

**O servidor aplicava duas regras em silêncio, e a tela nova precisava saber.**
Quem é do caixa só vê as concluídas de hoje (desde 29/10/2026, só o caixa que não é
gerente nem administrador: a regra 1 acima); quem não é administrador, só a própria
unidade. `indexAction` reescrevia o pedido e devolvia a lista sem dizer que ela não
era a pedida: a tela pediria janeiro e mostraria hoje, com "janeiro" nos campos. As
duas regras saíram do controller para `ISVenda\Regra\RegraDaListagemDeVendas`, e a
resposta ganhou `recorte` -- o período, a situação e a unidade APLICADOS, e
`travado` quando o período e a situação não mandam. A tela desliga os campos e diz
por quê. E a primeira consulta vai sem datas: o dia é o do servidor.

**As recusas novas**, antes da consulta:

- `codigo`, `pagamento`, `colaborador` e -- para o administrador -- `unidade` são
  NÚMERO: só dígitos, até nove, e o código maior que zero. Iam crus para a
  consulta: no PostgreSQL uma letra quebrava com 500; no MySQL o texto era
  convertido pelo prefixo -- `12a` virava 12 e casava com OUTRA venda. O código
  `0` era ignorado (`!empty('0')` é falso) e a lista vinha inteira; a unidade
  `-5` era aplicada com o recorte dizendo "todas". Nove é um teto conservador, e
  não a faixa da coluna: os ids de hoje têm oito dígitos, e `vendas.id` é INT no
  MySQL e BIGINT no PostgreSQL.
- `nps` e `desconto` só `0` ou `1` -- e o `desconto` era o D229: `'0'` trazia as
  vendas COM desconto. Consertado na consulta, para a tela antiga também, com o
  nulo contado como "sem" (536.783 vendas de 2015 a 2020 têm a coluna nula).
- Nenhum parâmetro em lista (`?codigo[]=1`).

E `?de=` vazio deixou de virar `'//'` na consulta, e a página além da última
passou a vir com o número da que o paginador entregou.

### A revisão adversarial, e o que ela mudou

Três revisores só de leitura -- servidor e contrato, front, testes e texto --,
sobre uma cópia congelada da fatia, e um cético por achado: **29 achados, 22
confirmados**, nenhum agente morto. Os 22 estão consertados:

- **um defeito antigo da consulta** (D231): o "Total descontos" com filtro de
  colaborador ou de forma de pagamento somava a venda uma vez por item ou por
  pagamento no MySQL, e dava 500 no PostgreSQL. A tela antiga tem os dois
  filtros; o número inflado estava em produção;
- **o controle do D229 não media nada**: o gerente de fixture é do caixa, só vê
  as concluídas de hoje, e a lista chegava vazia. Os casos `9h` e `9i` gravam duas
  vendas de hoje -- com e sem desconto -- e conferem cada filtro; o `12c` do fluxo
  confere que um código que não é o da venda não traz nada; e os dois passaram a
  exigir o recorte de quem é do caixa, que é o que prende a ligação da regra no
  controller;
- **o medidor de `/adm/api`** mudou de comportamento com o privilégio novo do
  gerente, e o comentário dele contava outra história -- corrigido o texto, e dito
  que a listagem depende da venda que o ensaio do fluxo finaliza antes dele;
- **na tela**: o formulário de quem é do caixa destravava durante a carga e depois
  de uma falha (agora vale o último recorte); a resposta de uma releitura apagava
  o que se digitava enquanto ela vinha; abrir uma venda ou virar a página durante
  a releitura levava a estados sem saída ("página 4 de 2"); a página que falhava
  ao virar deixava o foco no vazio; e a chegada do detalhe não era dita ao leitor
  de tela;
- **nos testes e no texto**: um caso de nota nula que olhava a linha errada, um
  que se provava com o próprio valor digitado, e três frases falsas -- o motivo dos
  nove dígitos, o "no MySQL vira 0" e a conta de casos deste diário.

Um mutante sobreviveu depois dos consertos, e o defeito era do código: comparar o
recorte por valor tinha virado enfeite -- quem protege o digitado passou a ser o
registro do que foi enviado. Saiu.

**Provado:**

- **Caracterização**, nos dois bancos: 21 casos novos no `ListagemDeVendasTest`,
  e o `PrivilegioApiTest` contando nove privilégios do gerente;
- **dezenove mutantes do servidor**, todos vermelhos;
- **os ensaios por HTTP**: o de recusas ganhou seis casos (9e a 9j) e o do fluxo,
  dois (12b e 12c);
- **o front**: 33 casos novos, build e lint; **trinta e cinco mutantes do front**,
  todos vermelhos -- dois sobreviveram no caminho: um ganhou o caso que faltava (o
  "próxima" na última página), e o outro era o enfeite acima;
- **quatro mutantes do controller**, que só o HTTP pega -- o recorte fora da
  resposta, a recusa ignorada, a página pedida no envelope e a sessão trocada na
  chamada da regra --, cada um vermelho no caso escrito para ele;
- **no navegador**, contra o banco de contrato, como o gerente de fixture (que é
  do caixa): o aviso de "concluídas de hoje" com o período desligado; as duas
  vendas que o ensaio do fluxo finalizou, com R$ 139,90 no total -- 89,90 e 50,00
  no banco --; o detalhe com o foco no título e a chegada dita na região viva; o
  "voltar" devolvendo o foco ao número; o filtro por código; e o código 0 recusado
  na tela. O navegador achou dois defeitos de desenho que os testes não acham: em
  1366 px a grade dos filtros deixava "Desconto" sozinho numa segunda linha, e no
  painel estreito a tabela espremia o nome do cliente em quatro linhas. Os dois
  estão corrigidos (quatro colunas, duas no telefone; a tabela com largura mínima
  e rolando dentro do cartão).

**O que ficou anotado:** a exportação em XLS da tela antiga ignora os filtros de
desconto e de colaborador (D230).
