# Fase 2 — a décima quarta fatia: os cupons

Escolhida em 09/09/2026. Cupom é **desconto**: quem mexe nele mexe no preço que
o PDV cobra. E foi ao ler esta tela que apareceram o D139 — editar cupom
alcançava a franquia vizinha — e o D141, que fazia campanha criada como
"Inativo" nascer no ar.

---

## O recorte

| endpoint | verbo | origem |
|---|---|---|
| `/adm/api/cupons` | `GET` | `indexAction` |
| `/adm/api/cupons` | `POST` | `novoAction` → `CupomService::insert` |
| `/adm/api/cupons/:id` | `GET` | `editarAction`, leitura |
| `/adm/api/cupons/:id` | `PUT` | `update` |
| `/adm/api/cupons/:id/emitidos` | `GET` | `estatisticasAction` |
| `/adm/api/cupons/:id/ativacao` | `POST` | `ativarRemover` |
| `/adm/api/cupons/:id/desativacao` | `POST` | idem |

**Fica de fora:**

| o quê | por quê |
|---|---|
| `selecionarProdutosPorUnidade` | popula uma lista suspensa do formulário; a API devolve produtos pelo recurso de catálogo |
| `pesquisarCupom` | **roda no PDV**, e não nesta tela — entra quando houver recurso de PDV. Ver D138 |

---

## `/emitidos`, e não `/estatisticas`

O nome mudou de propósito. "Estatísticas" sugere números agregados; o que a
consulta devolve é a **lista de quem ganhou** o cupom — uma linha por cliente,
com a venda que originou.

---

## O recurso devolve o valor, e não o rótulo

`regra` sai como `consumiu`, e não como *"Acabou de comprar/realizar"*.
`momento` sai como `checkout`, e não como *"Check-out (saída cliente)"*.

Os rótulos vivem em `Arrays::cuponsRegra()` e `Arrays::cuponsMomentos()`, e são
texto de **interface**. Mandar o rótulo faria a tela comparar frases para decidir
o que fazer, e uma correção de digitação viraria defeito.

E há um motivo concreto: **`cuponsMomentos()` devolve `null`** para qualquer
valor fora das duas opções. Um recurso que mandasse o rótulo mandaria `null` no
lugar do dado, e quem recebesse não saberia se o cupom não tem momento ou se o
momento é desconhecido.

---

## Dois números que não são dinheiro, e um nome que quer dizer duas coisas

`desconto` é **porcentagem** e `validade` são **dias** — as duas colunas são
`int`, e as duas saem como inteiro. A regra 7 fala de dinheiro; aplicar texto
decimal aqui faria a tela desfazer uma conversão que nunca precisou existir.

E o cuidado com o nome:

| | |
|---|---|
| `Cupom::$validade` | **dias** de duração do cupom quando for emitido |
| `CupomCliente::$validade` | **data** de vencimento do cupom já emitido |

Mesma palavra, tipos diferentes. O recurso chama o primeiro de `validadeDias`, e
o teste cobra os dois lados.

---

## O serviço não alterna, e mesmo assim há 409

`CupomService::ativarRemover($cupom, $status)` recebe o estado **explícito** —
diferente dos convênios e das unidades, onde o serviço inverte o que encontra.
Aqui `POST .../ativacao` duas vezes já seria idempotente sozinho.

O 409 fica assim mesmo, **por contrato**: as outras quatro fatias com ativação
respondem 409 quando o estado já é o pedido, e uma tela que recebesse 200 de um
recurso e 409 de outro para a mesma situação teria de tratar cada um de um jeito.
E o 409 diz algo útil — *"nada mudou porque já estava assim"* — que o 200
esconderia.

---

## Uma escrita dentro de um GET que a API não repete

`indexAction` do ERP chamava, até 11/10/2026, `validarVencimento()`, que
**desativava** campanhas vencidas. Era a mesma forma do NPS na listagem de vendas:
escrita dentro de uma leitura.

**Este parágrafo dizia, até 28/09/2026, que a rotina já rodava pelo cron — e não
roda.** `cupons/cuponsValidarVencimento` (01:20) só vence os cupons EMITIDOS; quem
vira o estado da CAMPANHA encerrada é só esta escrita dentro do GET da lista
antiga, e só na unidade de quem a abre. Quando o menu abrir a tela nova, ninguém
mais vira (o D264). Por isso o recurso passou a dizer `encerrada`, calculada no
servidor com o relógio da emissão — e não depende de ninguém virar o estado.
Fechar a campanha encerrada pela rotina era pergunta ao dono, e ficou de fora até
11/10/2026: pela resposta 93, a rotina da noite a desativa, em todas as unidades,
quando ela não tem mais cupom válido, e a lista antiga deixou de gravar (o D264,
abaixo).

---

## O `PUT` grava só o que veio — desde 28/09/2026

Até a fatia da tela nova, `CupomService::update()` hidratava o arranjo cru sobre a
campanha e buscava os dois produtos e a unidade **sem perguntar se vieram**; o
controlador preenchia os ausentes com o valor atual. Pela tela antiga, o mesmo
`update()` recebia o POST inteiro: o `id` do campo oculto escolhia QUAL campanha
gravar, a `unidade` postada a mudava de franquia, e o `status` escondido desfazia a
troca de estado feita em outra tela.

`update()` saiu. `alterar($cupom, $mudancas)` grava cada chave presente pelo seu
setter — nome, regra, momento, desconto, validade, data limite e os dois produtos —,
e `id`, `status` e `unidade` não têm por onde entrar: a campanha é a da rota, e o
estado muda só por `/desativacao` e `/ativacao`.

---

## Os dois defeitos que a leitura achou

### D139 — editar e desativar alcançavam a franquia vizinha

`editarAction` e `ativarRemoverAction` faziam `find($id)` e seguiam.
`estatisticasAction`, dez linhas abaixo, já conferia: **a leitura era guardada e
as duas escritas não**.

E a listagem tinha a mesma porta por outro caminho — aceitava qualquer
`?unidade=` da query. A lista suspensa do filtro restringia a escolha, e o
servidor não: **o controle da interface era o único controle**, o mesmo desenho
do botão de devedores (D136).

146 cupons em 27 unidades. A regra virou o método `daMinhaUnidade()`, e o portão
`conferir-recorte-por-unidade.php` vigia as três ações.

### D141 — campanha criada como "Inativo" nascia no ar

O construtor hidratava e depois impunha `status = true`. O formulário tem o
seletor; quem criava uma campanha desligada, para revisar antes de publicar,
recebia ela valendo no checkout do primeiro cliente.

É o quinto desta família — D125 na `Produto`, D130 na `Unidade`. Nascer ativo
continua sendo o padrão; a diferença é que agora é **padrão**, e não imposição.

---

## 28/09/2026 — o servidor para a tela nova dos cupons

A fatia da tela nova consertou o servidor antes do botão, e as duas telas — a
antiga e a API — passaram pela mesma regra, a `RegraDoCupom` (pura, no molde da
`RegraDoConvenio`). Os débitos são o D262 ao D279.

### O recorte, de novo

| endpoint | verbo | o que mudou |
|---|---|---|
| `/adm/api/cupons` | `GET` | `unidade` ausente é a da sessão; `todas`, só ao administrador; um id, uma das que quem pede pode escolher; o resto é 422. A linha traz `momento`, `validadeDias`, `dataLimite`, `encerrada` e os ids |
| `/adm/api/cupons/unidades` | `GET` | **novo**: as unidades que quem pede pode escolher, com a guarda de ler |
| `/adm/api/cupons/produtos?unidade=` | `GET` | **novo**: os produtos ativos da unidade, de qualquer tipo, para o cadastro |
| `/adm/api/cupons` | `POST` | a regra inteira; a unidade é uma das que quem pede pode escolher (era 500 na FK); nasce ativa e no fechamento |
| `/adm/api/cupons/:id` | `GET` | `encerrada`, `emitidos` {total, validos, vencidos, usados, validosAte}, produto {id, nome, tipo, ativo}, unidade {id, nome, ativa}, `criadoPor` |
| `/adm/api/cupons/:id` | `PUT` | o corpo vazio é 400; a unidade não muda (422); o produto ligado serve mesmo inativo, **só no seu campo** |
| `/adm/api/cupons/:id/produtos` | `GET` | **novo**: os ativos da unidade da campanha, mesmo a desativada; os ligados vêm na campanha, cada um no seu campo |
| `/adm/api/cupons/:id/emitidos` | `GET` | `situacao=valido\|vencido\|usado`; `usado=nao` filtra (não filtrava); o emitido sem venda aparece, com `venda: null` |

### O que a regra recusa, com o campo nomeado

O nome acima de 75 (o PDV grava "CUPOM VIP: ", o nome e o número num campo de 100),
com emoji, `<` e `>` ou quebra de linha; a regra fora das duas; o momento
`checkin` (a emissão no check-in está desligada no sistema desde o fim de 2025); o
desconto que não é inteiro de 1 a 100 — o `50.9` era truncado calado; a validade
que não é inteiro de 1 a 3650 — `3000000000` dava 500; o produto que não está
entre os ativos da unidade da campanha — bastava existir, de qualquer franquia; a
data que não existe, e no cadastro a de antes de hoje (no `PUT` ela é aceita: é o
jeito de encerrar sem tirar o desconto de quem já ganhou); e as chaves que a API
não altera — `estado`, `status`, `validade`, `encerrada`, `id` —, cada uma com a
sua frase. Na tela antiga a regra é a mesma, e a edição só confere o que mudou: a
campanha antiga de nome acima de 75 continua salvando os outros campos.

### As decisões que tomei, e o caminho de volta

Sete perguntas do desenho eram do dono; tomei as reversíveis, e todas estão no
roadmap: (1) a rotina **não** fecha a campanha encerrada — a tela a mostra
encerrada, e a lista antiga continua virando o estado quando alguém a abre; (2) o
desconto e o produto **não** travam com cupons válidos — a tela avisa quantos
mudam; (3) o check-in é recusado em campanha nova e na troca; (4) a campanha
aceita todo produto ativo da unidade, como o ERP; (5) a guarda dos emitidos na API
continua `ler`, e a tela só os mostra a quem escreve (exposição de dados é débito
futuro, por decisão da casa); (6) a campanha com produto desativado só ganha
aviso; (7) a unidade da campanha não muda.

E uma pergunta nova, que o crítico do desenho achou: **a comanda nova emite cupom e
nunca resgata** (o D276). Ela fica com o dono.

### A revisão adversarial do servidor

Cinco lentes (o ERP, a API, o recorte, os dados e as provas), cada achado com um
cético que tentou derrubá-lo: 17 achados, 14 confirmados, nenhum agente perdido. Os
consertos, e o caso que prende cada um:

- **o ligado passava de um campo ao outro**: a lista de produtos aceitos era uma só,
  e o oferecido desativado podia virar a regra de "nunca comprou" — que ninguém
  compra, então todo cliente ganhava. Agora cada campo aceita os ativos da unidade e
  o SEU ligado (`RegraDoCupom::conferir`, o parâmetro `$ligados`); o `<select>` do
  ERP e o `/cupons/:id/produtos` seguem a mesma regra (caso 45 do ensaio);
- **o `/ativacao` e o `/desativacao` da API** conferem a unidade na guarda própria,
  fora de `cupomDaRota`, e nada a vigiava; e o ramo padrão do filtro da lista — o
  que o gerente usa em todo pedido — também não. Os dois entraram no
  `conferir-recorte-por-unidade.php` (casos 48, 51 e 52);
- **as estatísticas do ERP perdiam o filtro na página 2**: os filtros eram montados
  e nunca chegavam à view, e o `!empty` jogava fora o "Não" e o "Vencidos" (o `0`).
  A lista antiga fazia o mesmo com o "todas" do administrador (o D278, casos 56
  e 57);
- **o que ia cru a um booleano ou a um `int4`** — `status`, `utilizado`,
  `situacao`, e a unidade acima de 2.147.483.647 — dava 500 no PostgreSQL. Agora
  vale `0` ou `1`, e a unidade é uma das que o administrador pode escolher (o D279,
  casos 58 e 59);
- **o rótulo da unidade** saía cru na lista antiga, que só o administrador vê
  (`TelasDosCuponsDoErpTest`);
- **as provas que faltavam**: a alteração com valor ruim, o serviço gravando cada
  chave, o editar do ERP com o produto de outra franquia e com o nome de 93, e o
  `ativarRemover` que grava o estado pedido (casos 46, 47, 49, 50, 53, 54 e 55);
- **o ensaio restaura a 90001000 e a 90001150 da fixture** antes de cada caso e no
  fim, e confere: um caso que falhava de propósito deixava a vizinha alterada, e as
  rodadas seguintes seguiam vermelhas.

Cada caso novo foi medido contra o seu mutante: os 13 mutantes aplicados de uma vez
deixaram vermelhos os 13 casos-alvo, cada um pelo seu motivo, 4 testes de
caracterização e o portão do recorte; desfeitos, 59 de 59.

## 28/09/2026 — a tela nova dos cupons

Marketing > Cupons abre a tela nova (`Cupons.tsx`, `CuponsLigado.tsx`), no molde dos
convênios: a lista com a frase curta de cada campanha e o selo, a campanha com o
que ela faz por extenso, os avisos de por que não emite, os emitidos em números e
"como funciona"; o formulário com a prévia e o aviso da promessa; e os cupons
emitidos, que só quem escreve vê, como no ERP.

- **O selo** diz o que o estado não diz: `emitindo`, `parada` (a de check-in, a da
  unidade desativada, a de "comprou" com o produto desativado), `encerrada` (o fim
  passou; o servidor decide) e `desativada`.
- **O gerente monta a tela com um pedido só**; o administrador pede também as
  unidades, e é o único que vê o filtro e a escolha de unidade no cadastro. Trocar
  a unidade relê os produtos e limpa os escolhidos.
- **A tela não promete desconto no caixa novo** (o D276): "como funciona" diz que a
  comanda nova ainda não aplica o cupom.
- **O que não entrou**: os filtros por data dos emitidos (ficam na tela antiga, e o
  cartão do histórico aponta para ela), a exclusão (o ERP não tem), as contagens por
  linha na lista, e exportar.

### A revisão adversarial da tela

Cinco lentes (foco, estado, contrato com o servidor, o que a tela diz, e o visual),
cada achado com um cético: 25 achados, 24 confirmados — todos de gravidade baixa
depois do cético — e consertados, nenhum agente perdido. Os que mudaram o que a
pessoa vê:

- **a campanha de check-in prometia emitir**: "o que faz", "como funciona" e a
  prévia diziam "ganha, ao fechar a venda" de uma campanha que não dá cupom a
  ninguém. Agora falam no condicional, e o filtro diz "no check-in (não emite)";
- **a desativada e encerrada** dizia ao mesmo tempo que os cupons válidos não dão
  desconto e que seguem dando; ativar e salvar prometiam desconto com o produto
  oferecido fora do catálogo. Só a campanha ativa, com o produto à venda, promete;
- **o selo da lista dizia "emitindo"** da campanha que o detalhe mostra parada: a
  linha passou a trazer o estado da unidade e dos dois produtos;
- **desativar a que já não emite** dizia "para de emitir" e mandava pôr data de fim;
- **o foco da marca** pulava a validade e caía no produto oferecido; o produto
  deixado em "escolha…" na edição sumia calado do `PUT`, com "Nada foi alterado.";
- **o duplo clique no "‹"** saía duas telas, e o Enter segurado agia no botão da
  tela seguinte; a unidade escolhida que saía da lista relida sumia do `<select>`;
- **o que o leitor de tela não ouvia**: a falha dos produtos e das unidades, e o
  "nenhum produto com esta procura"; e os dois "tentar de novo" ganharam nome;
- **o `--atencao` do tema claro** ficava em 4,32:1 sobre a superfície-2 e em
  4,499:1 na linha sob o mouse. Virou `#96610F`: 4,52 no pior caso, e a olho a mesma
  cor.

Os 21 mutantes do front — 14 da tela e 7 dos consertos da revisão — morreram, cada
um no seu caso.

O selo da lista pediu o servidor: `CupomRepository::listagemIndex` projeta o estado
da unidade e dos dois produtos, e `CupomJson::daListagem` os devolve como `ativa` e
`ativo`. `CupomTest::testALinhaDaListaTrazOEstadoDaUnidadeEDosProdutos` desliga um
de cada vez — o oferecido passa antes a outro produto, para que trocar as duas
colunas fique vermelho. Dos três mutantes do conserto (a unidade sempre ativa, as
colunas trocadas, o oferecido sempre ativo), o terceiro sobreviveu à primeira versão
do caso, que nunca desligava o oferecido; com o caso completo, os três morreram.

## 11/10/2026 — as respostas 93 a 97 do dono

As cinco perguntas que esta fatia deixou com o dono (a 1, a 2 e a 6 do desenho, e as
do D272 e do D276) voltaram respondidas no lote. **Três estão feitas** (o D264, o D272 e
o D274, corrigidos) **e duas estão feitas em parte** (o D271 e o D276, parciais): o que
falta em cada uma está no fim de cada item, e no corpo do débito. Em 12/10/2026 o D271
passou a **preparado** (a resposta 83 do dono levou as duas colunas à cópia local, nos
dois bancos), e falta só a produção, no passo 0 do corte; o D276 segue parcial.

- **A rotina da noite fecha a encerrada** (a 93, o D264, corrigido): `cuponsValidarVencimento`
  vence os cupons e desativa a campanha que passou do fim sem cupom válido, em todas
  as unidades; a lista antiga não grava mais no GET.
- **O cupom guarda o desconto e o produto do dia** (a 94, o D271, **preparado**): duas
  colunas novas em `cupons_clientes`, que a emissão grava e o resgate, a comanda nova e
  o aplicativo leem; as nulas -- os cupons de antes -- seguem a campanha. As colunas
  estão no repositório, na cópia local desde 12/10/2026, nos dois bancos (a resposta
  83 do dono), e entram em produção no passo 0 do corte. A base do teste do BVIP só as
  ganha na próxima cópia exportada, e nela o cupom segue a campanha, sem quebrar nada:
  elas ficam fora do mapeamento do Doctrine, lidas pela conexão (o D473). O detalhe
  ganhou `emitidos.validosQueSeguemACampanha` e `guardaODoDia`, e o aviso de alterar e
  o "como funciona" dizem o que vale no banco de cada um. **Falta:** a migração em
  produção, no passo 0 do corte.
- **Sem carência** (a 95, o D272, corrigido): a conta de "-3 dias" saiu da rotina.
- **A campanha de produto oferecido desativado não emite** (a 96, o D274, corrigido), e
  o aplicativo não mostra os cupons do produto desativado; o selo dela é "parada".
- **A comanda nova aplica o cupom** (a 97, o D276, **parcial**) ao lançar o produto dele
  para o cliente, um cupom por linha, e recusa o "CUPOM VIP:" digitado. "Como funciona"
  diz isso agora, e não promete mais o contrário. **Falta, com o dono** (o lote,
  CUPONS): o outro desconto no item que leva o cupom (por ora, recusado), a linha de
  mais de uma unidade (por ora, como no PDV antigo: o cupom vale na linha inteira e se
  gasta um), e a linha que vem da agenda (por ora, como no PDV antigo: nunca leva o
  cupom); e o convênio, que é a pergunta 90 (o D277).

Foram ao lote (CUPONS), além dessas três da comanda nova, a campanha de "nunca comprou"
com só o produto da regra desativado (por ora, continua emitindo).
