# Fase 2 — a décima quinta fatia: os pacotes

Escolhida em 09/09/2026. A quinta fatia já devolvia os pacotes **de um cliente**
— o que ele comprou e quantas sessões restam. Faltava o outro lado: o **catálogo**
de pacotes que a franquia vende.

E foi ao ler essa tela que apareceu o D142, o quarto defeito de recorte por
unidade em duas noites — e o de maior alcance.

---

## O recorte

| endpoint | verbo | origem |
|---|---|---|
| `/adm/api/pacotes` | `GET` | `indexAction` |
| `/adm/api/pacotes` | `POST` | `novoAction` → `PacoteService::insert` |
| `/adm/api/pacotes/:id` | `GET` | `editarAction`, leitura |
| `/adm/api/pacotes/:id` | `PUT` | `update` |
| `/adm/api/pacotes/:id/ativacao` | `POST` | `alterStatus` |
| `/adm/api/pacotes/:id/desativacao` | `POST` | idem |
| `/adm/api/pacotes/:id/itens` | `POST` | `adicionarServico` |
| `/adm/api/pacotes/:id/itens/:item/exclusao` | `POST` | `removerServico` |
| `/adm/api/pacotes/exportacao` | `GET` | `exportarAction` (nasceu em 06/11/2026, a fatia 90: a ação que o botão escondido pedia) |

---

## Pacote é produto, e o tipo é parte da identidade

Não há tabela de pacotes: eles vivem em `produtos`, e `tipo = 'pac'` os separa.
A sexta fatia moldou o produto e deixou `pac` **fora dos dois catálogos** de
propósito — um pacote não é item de venda avulso, é um conjunto de sessões com
validade.

`PacoteJson::completo()` **delega** a `ProdutoJson::completo()` e acrescenta a
validade e os itens. Duas cópias da mesma forma divergiriam na primeira semana.

E toda ação confere o **tipo** junto com a unidade. Sem isso,
`PUT /adm/api/pacotes/:id` mudaria o preço de um serviço avulso pelo endereço
errado — o ERP já fazia essa conferência em todas as ações.

---

## Os itens vêm da consulta, e não da coleção

`selecionarItensPacote()` filtra `status = true`: os itens removidos ficam na
tabela e somem da lista, que é o que a tela mostra. A coleção `getPacoteItens()`
da entidade não filtra.

Usar a consulta mantém o recurso e a tela falando das **mesmas linhas** — e evita
a incoerência que o D135 registrou no financeiro, onde a lista filtra e o total
não, e os dois discordam depois de qualquer exclusão.

---

## Dois totais que podem divergir de propósito

`totais.sessoes` é a soma das quantidades dos itens; `valorVenda` é o preço do
pacote. **Pacote é desconto**: o cliente paga menos que a soma das sessões
avulsas, e nada obriga os dois números a baterem.

Devolver os dois deixa isso visível em vez de escondido — e impede que uma tela
nova "conserte" a diferença calculando um a partir do outro.

---

## O item é desativado, e não apagado

`removerServico()` marca `status = false`. A linha fica na tabela porque
**3.474 pacotes já vendidos** apontam para ela: apagar deixaria esses registros
de cliente sem a referência do que compraram.

O endereço responde `409` quando o item já foi removido — estado, e não forma.

---

## O serviço do item precisa ser da mesma unidade

`adicionarServico()` do ERP aceita qualquer id de produto. O recurso exige que o
serviço seja **da mesma unidade do pacote**: sem isso, o pacote de uma franquia
daria direito a um serviço de outra, e o PDV não teria como cumprir.

---

## A fronteira do dinheiro, de novo

```php
Produto::setValorVenda($v)        // -> Monetario::removeMask($v)
PacoteItem::setValorSessao($v)    // -> idem, por omissão
```

`removeMask("1500.00")` devolve `150000` — o ponto é separador de **milhar**.
Cento e cinquenta mil no lugar de mil e quinhentos, calado. O controlador traduz
com `Monetario::deDecimal()` antes de entregar, e o teste prova as duas pontas.

É a terceira vez que essa tradução aparece — saídas, cupons, pacotes. O padrão já
está claro: **todo setter de dinheiro desta casa espera a máscara brasileira**, e
a API fala decimal de protocolo.

---

## O defeito que a leitura achou (D142)

Quatro ações alcançavam pacote de qualquer franquia, e **duas delas não conferiam
nem privilégio**:

| ação | privilégio | unidade |
|---|---|---|
| `indexAction` | tinha | **não** — aceitava qualquer `?unidade=` |
| `editarAction` | tinha | **não** |
| `ativarRemoverAction` | tinha | **não** |
| `adicionarServicoAction` | **não tinha** | **não** |
| `removerServicoAction` | **não tinha** | **não** |

As duas últimas **mudam o que o pacote contém** — quais serviços, quantas
sessões, por quanto sai cada uma.

A medida: **238 pacotes em 21 unidades, 143 ativos, 445 itens — e 3.474 já
vendidos a clientes.** São os 3.474 que dão peso: um pacote vendido continua
sendo consumido sessão a sessão, e mexer na composição muda o que o cliente ainda
tem direito a receber.

A correção é segura, e a diferença em relação ao **D138** importa: as duas ações
sem privilégio são chamadas de dentro da tela de editar pacote, que já exige
`configuracoes/pacotes`/`escrever`; e a lista suspensa do filtro só oferece a
própria unidade a quem não é administrador. **Ninguém alcançava outro pacote pela
interface**, então fechar não tira nada de quem trabalha.

## 28/09/2026 -- a tela nova dos pacotes

"Pacotes", no menu de Configurações, deixou de abrir a tela antiga. A tela nova
lista os ativos, com busca por nome e situação, e a página é de 20. Cada pacote abre
o cadastro: o valor, a validade (os dias para usar depois da compra), a unidade e os
serviços -- cada um com as sessões e o valor de cada sessão --, com a **soma das
sessões ao lado do valor do pacote**: os dois podem divergir de propósito, e a tela
mostra os dois em vez de esconder a diferença.

Quem escreve nos pacotes **cadastra, altera, desativa e ativa** aqui mesmo, e
**compõe** o pacote: acrescenta um serviço (da lista que a tela antiga oferece) e
remove um. Como no ERP, o pacote nasce vazio, e abre avisando que os serviços entram
agora -- o PDV recusa o pacote vazio. **Remover pergunta no próprio botão**, e o duplo
clique não confirma; o item removido não é apagado, porque os pacotes já vendidos
apontam para ele.

O botão se chama **remover** -- a pedido, na mesma manhã; o primeiro desenho
dizia "tirar". O nome acessível segue o rótulo: "remover Corte, 5 sessões de R$
80,00", "confirmar: remover…", "não remover…".

### O servidor, antes da tela

- **D247 -- o item do pacote aceitava qualquer produto**, de qualquer franquia, no
  ERP; e, na API, qualquer produto da unidade -- a prateleira, o Lava VIP, o serviço
  desativado, o próprio pacote. A quantidade e o valor também não tinham regra. O
  item é agora um dos serviços que a tela antiga oferece, nos dois caminhos, e as
  regras moram em `RegraDoPacote`, medidas pela caracterização.
- **A lista do `<select>`**: `GET /adm/api/pacotes/:id/servicos`, com a guarda de
  compor -- os serviços ativos, do tipo `ser`, **da unidade do pacote** (o
  administrador compõe o de outra unidade). A lista do catálogo não servia: pede
  outro privilégio, a unidade da sessão e página de 20.
- **O item sai com o id do serviço**, e **na ordem em que entrou**: no PostgreSQL,
  sem o `ORDER BY`, a linha alterada ia para o fim, e a lista pulava.
- **O escape das duas telas antigas** (D203): a lista de pacotes e a tabela de
  serviços imprimiam o nome cru, e a API grava o nome que vier.

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

Cinco revisores, um por dimensão (o servidor, a tela antiga, o estado da tela nova,
o foco, a cobertura), e um cético para cada achado. **30 achados, 28 confirmados, 2
refutados, nenhum sem veredito** -- e o repositório conferido pelo `sha1` depois: os
revisores não mexeram em nada. O que mudou:

- **A regra do ERP que eu tinha acabado de escrever deixava passar o valor e o
  "00"**: o valor da sessão só passava por `empty()` -- `-80,00`, `80,005` e `abc`
  entravam --, e `00` não é vazio para o PHP, e gravava um item de zero sessões. A
  regra do formulário foi para `RegraDoPacote`, ao lado da da API, e a
  caracterização mede as duas.
- **O que a API não altera passava calado**: o `PUT` com `valorVenda` -- o nome que
  o próprio `GET` devolve -- respondia 200 sem gravar. Agora é 422 com o nome, como
  no catálogo.
- **Quatro caminhos em que a tela ficava presa**: remover e o formulário do item
  dividiam o contador de pedidos, e o "desistir" (ou o "acrescentar") durante uma
  remoção descartava a resposta dele -- o "removendo" ficava de pé para sempre, e a tela
  inteira, desabilitada; a remoção durante a carga dos serviços deixava o botão em
  "abrindo os serviços…"; o editar durante a carga abria o formulário do item por
  baixo da edição; e a releitura do 409 chegava com a edição já aberta, e o `PUT`
  desfazia o que a outra aba tinha gravado. Os serviços ganharam contador próprio, o
  formulário do item espera a remoção, e o 409 segura a tela até a releitura chegar.
- **O foco**: o botão de acrescentar sumia enquanto os serviços vinham, e o foco
  caía no vazio (agora o botão fica, com o foco, e a falha aparece acima dele); a
  resposta da remoção levava o foco ao título mesmo de quem já tinha ido a outro
  campo; e o botão de desativar -- um só, que vira "ativar" -- não tinha a guarda do
  duplo clique nem a do Enter seguro que o remover tem.
- **Os nomes**: dois itens do mesmo serviço (um pago e um de brinde) davam dois
  botões "remover Corte, 5 sessões" -- agora o valor da sessão entra no nome; e a nota
  da unidade sem serviço ativo não era lida com o campo que recebe o foco.
- **A cobertura**: a regra do ERP, a guarda de compor (o colaborador do ensaio não
  tinha privilégio nenhum, e trocar escrever por ler passava verde), a unidade do
  pacote na lista dos serviços, o aparo do nome no cadastro, o escape das duas telas
  antigas, o nome de cada serviço na resposta, e sete guardas da tela sem caso.

**Os refutados:** o `ORDER BY` que só o PostgreSQL mede (no InnoDB, arrancá-lo não
muda saída nenhuma -- é equivalente lá, e medido onde o defeito existe); e o `POST`
de pacote incompleto sem ensaio (o `medir-adm-api-no-postgres.php` manda corpo vazio
a todo `POST` da API).

### Os mutantes

**128, todos mortos**, cada um pelo caso que devia: **front, 84** (a tela, o invólucro
e a lógica pura dos dois formulários); **servidor, 38** (as duas regras, a consulta
dos serviços do pacote, o id e a ordem dos itens, o escape das duas telas antigas),
pela caracterização; **controller, 6** (as duas guardas de compor, o aparo no
cadastro e na alteração, a lista que o item aceita, o nome de cada serviço), pelo
ensaio de recusas. O que a rodada ensinou:

- **Dez guardas do invólucro sobreviviam, e não eram enfeite.** "Só a resposta do
  último pedido vale" e "uma escrita por vez" moram no invólucro, e a tela as cobre
  com o "ocupado": nenhum clique chega nelas. Um arquivo novo
  (`PacotesLigado.contrato.teste.tsx`) troca a tela por um dublê que só guarda as
  props e chama as ações na ordem que a tela não deixa -- e os dez morreram, cada um
  no caso escrito para ele.
- **O `ORDER BY` sobrevivia no PostgreSQL**, onde o caso dizia medi-lo: alterar só a
  quantidade da primeira linha não mudava a ordem física. O caso agora tira uma
  linha e a põe de volta com o mesmo id -- a física passa a contrariar a do id -- e o
  mutante morreu. No MySQL continua equivalente: a tabela é guardada pela chave.
- **Um mutante tirou código**: o `is_string` do valor na regra do ERP não guardava
  nada (o arranjo já cai no `deDecimal`), e saiu.

### No navegador

Contra o banco de contrato, como o gerente da fixture, com o privilégio de escrever
nos pacotes dado à mão (a fixture não o dá). A lista abre com o foco no título; o
pacote, com o foco no nome dele, a soma das sessões e o valor lado a lado. Acrescentar
um serviço mantém o botão, com o foco, enquanto a lista vem (a unidade da fixture
levou uns oito segundos), abre o formulário com o foco no `<select>` -- os dois
serviços ativos da unidade --, e grava: a linha nova, a soma de R$ 475,00 contra os
R$ 400,00 do pacote, o aviso, e o foco de volta ao botão. Remover pergunta no próprio
botão; confirmar desabilita a tela inteira enquanto o pedido corre, e depois leva o
foco ao título, com o aviso na descrição. A alteração manda só o valor; desativar e
ativar mantêm o foco no mesmo botão; voltar relê a lista (R$ 450,00) e devolve o foco
à linha. O cadastro vazio marca os três campos e leva o foco ao nome; o cheio grava o
nome aparado e abre o pacote vazio com o aviso. Em 375 px, no tema escuro, a página
não rola de lado: a tabela dos serviços rola dentro do cartão.

**O que o navegador achou, e nenhum teste via:**

- **O duplo clique em "remover" caía no "não".** "remover" vira "confirmar", e o "não"
  aparece ao lado: a coluna crescia de 93 para 175 px e empurrava o botão para a
  esquerda, e o segundo clique do duplo caía no "não" que surgira embaixo do
  ponteiro. Nada se perdia -- é a direção segura --, mas "o mesmo nó" deixava de ser
  o mesmo lugar para quem usa o mouse. A coluna passou a ter a largura da pergunta
  desde o começo, e o mesmo duplo clique agora só pergunta: o segundo clique cai no
  "confirmar", e a guarda o ignora. **A tela do estoque tem a mesma coluna**, e fica
  para medir no próximo passo pelo navegador.
- **Dois textos da tela com "--"** no lugar do travessão da casa.

**Provado:** a caracterização nos dois bancos (1014 cada); o contrato do aplicativo,
67 nos dois; os oito ensaios HTTP no MySQL e os nove no PostgreSQL, com o de recusas
em 177 de 177 nos dois (17 casos novos: do 115 ao 131); os portões (48 verdes e o
D50, o vermelho conhecido -- depois de dois vermelhos desta fatia: dois pares de
débitos com palavras em comum, anotados com o motivo, e a conta do módulo, que foi de
132 a 134 chamadas, com o par novo e o "sem teste" descendo de 158 para 157); 1027
testes no front, build e lint; 128 mutantes, todos mortos; e o navegador contra o
banco de contrato.

**O PostgreSQL pegou um caso meu.** Na primeira rodada, o ensaio de recusas parou no
caso 86 com "SEM RESPOSTA" -- a máquina carregada, e nada medido depois dali. Na
segunda, reprovou o 124: "esperava o item desativado, e não apagado; o banco tem
false". O item estava desativado. O caso lia a coluna com `fetchOne`, que devolve
`false` também quando a linha não existe -- e no PostgreSQL o `status` desligado chega
como `false`. No MySQL chega `0`, e o caso passava. Agora ele lê a linha.

---

## 06/11/2026 -- a lista em planilha (a fatia 90 da fila das telas que faltam, EXPORTAR-PACOTES)

A resposta 120 do dono pede destino para os botões escondidos que levavam a lugar nenhum. O "Exportar" da lista antiga
(`pacotes/index.phtml`, dentro de um `display-none`) apontava para `pacotes/exportar`, que nunca existiu (o D105). Agora:

- **`GET /adm/api/pacotes/exportacao?estado=&nome=&unidade=`** devolve a lista em XLSX
  (`ISConfiguracao\Exportacao\PlanilhaDosPacotes`): as colunas da lista antiga -- Nome, Valor, Status --, o cabeçalho em
  negrito, o valor como número com `#,##0.00`, o texto sempre como texto. O nome do arquivo é
  `pacotes-<ativos|inativos>-<aaaa-mm-dd>-<unidade-N|rede>.xlsx`, e os cabeçalhos HTTP são os dos relatórios
  (`attachment`, `Content-Length`, `Cache-Control: private, no-store`, `nosniff`).
- **O recorte e os filtros são os da lista**, lidos do mesmo ajudante (`filtrosDaLista`): a unidade da sessão (só o
  administrador escolhe; o zero é a rede), o estado (`ativo` ou `inativo`, o resto é `422` no campo) e o nome; sem a
  página. **A guarda é a da lista**, o `ler`: a planilha não leva nada que o `ler` já não abra (desde a fatia 97 ela leva a
  validade e o total de sessões, que a lista não mostra, mas que o `GET /adm/api/pacotes/:id`, com o mesmo `ler`, entrega). As recusas, na ordem: `401`,
  `403`, `422` -- com o estado fora da lista, quem não entrou recebe o 401 e quem não lê pacotes o 403, nunca o 422
  (provado em processo e por HTTP na verificação da fatia).
- **A tela antiga** ganhou `exportarAction`, com a mesma planilha, o `dadosRequest` da lista e listas fechadas no estado
  ("1"/"0") e na unidade (dígitos, ou vazia para a rede do administrador) -- o resto é 404, como as outras ações dela. O
  botão aparece, com a busca por `http_build_query`; o link que a lista "Todas" do administrador desenha leva a planilha
  da rede (o caso segue o `href`), e os cabeçalhos são os mesmos da API.
- **A tela nova** tem o "baixar a planilha (XLSX)" no pé da lista (o `BotoesDeExportacao` dos relatórios), com a busca que
  produziu a lista, desligado enquanto ela relê e ausente na lista vazia.

O que fica de fora, de propósito: a validade, a cota-parte e os itens de cada pacote. A lista não os mostra, e a planilha
é a lista em arquivo; se o dono quiser uma planilha do cadastro inteiro, é outra pergunta.

> **06/11/2026, a fatia 97 (PACOTES-PLANILHA-COLUNAS):** a pergunta foi ao lote e o dono respondeu 316 (b), "Acrescentar a
> validade e o total de sessões: duas colunas a mais, uma linha por pacote". O parágrafo acima vale para a cota-parte e os
> itens um a um, que continuam fora.

## 06/11/2026 -- a validade e o total de sessões na planilha (a fatia 97, PACOTES-PLANILHA-COLUNAS)

- **Duas colunas depois de Status**, nas duas portas: **"Validade (dias)"** (`produtos.pacote_validade_dias`, em número; o
  zero é zero e a vazia fica em branco -- nem zero, nem "sem prazo": o que o vazio quer dizer é do dono, o D731) e **"Total de sessões"** (a soma de `pacotes_itens.quantidade`
  dos itens com `status` ligado -- os que o cadastro mostra e a venda entrega; o item do serviço inativo conta, o item
  tirado não; zero no pacote sem item). Uma linha por pacote, como antes.
- **Numa consulta só**: a soma é uma subconsulta correlata na própria `listagemIndexPacotes`, acrescentada só quando ela é
  chamada sem a página (o que só a planilha faz); a lista paginada não mudou. O `PlanilhaDosPacotesTest` conta as consultas
  que leem `pacotes_itens` (uma, a da lista) com quatro pacotes na rede.
- O recorte, os filtros, as guardas (401, 403, 422), os cabeçalhos HTTP e o nome do arquivo não mudaram.
