# Fase 2 — a sexta fatia: o catálogo

Escolhida em 08/09/2026. O PDV vende produtos e serviços desde a primeira
fatia, e não havia como **listá-los** pela API nova — a comanda podia receber um
item, mas nada dizia quais itens existem.

---

## Um recurso para duas telas, e a ACL é quem decidiu

O ERP tem `ProdutosController` e `ServicosController`. Parecem dois assuntos.
Não são:

| | `ServicosController` | `ProdutosController` |
|---|---|---|
| permissão | `configuracoes/produtos` | `configuracoes/produtos` |
| consulta | `listagemIndexServicos` | `listagemIndexProdutos` |
| colunas | as mesmas seis | as mesmas seis |
| ordem, paginação | `nome, id` / 20 | `nome, id` / 20 |
| **diferença** | `tipo IN ('ser','lavavip')` | `tipo IN ('probar','proemp','proins')` |

A permissão que as guarda **já se chama "Configurações produtos/serviços"** — o
ACL sempre tratou as duas como uma coisa só. E a única diferença real entre as
consultas é o conjunto de `tipo`, mais um filtro de estoque baixo que só a de
produtos usa.

Então a API tem **um recurso**, `/adm/api/produtos`, com `catalogo` como filtro.

**Pacotes ficam de fora**, e por medição, não por gosto: `PacotesController` usa
`funcionalidade = 'pacotes'` — outra permissão. Unificar ali daria a quem tem
produtos o poder sobre pacotes, em silêncio. É o mesmo raciocínio que manteve o
`clientes/credito` de lado na quinta fatia.

---

## O recorte

| endpoint | verbo | origem |
|---|---|---|
| `/adm/api/produtos` | `GET` | `listagemIndexServicos` + `listagemIndexProdutos` |
| `/adm/api/produtos` | `POST` | `novoAction` → `ProdutoService::insert` |
| `/adm/api/produtos/:id` | `GET` | `editarAction`, leitura |
| `/adm/api/produtos/:id` | `PUT` | `editarAction` → `update` |
| `/adm/api/produtos/:id/ativacao` | `POST` | `ativarRemover` |
| `/adm/api/produtos/:id/desativacao` | `POST` | idem |

**Fica de fora:**

| o quê | por quê |
|---|---|
| estoque (`estoque`, `estoqueCancelar`, `retirada`, `retiradaPdv`) | movimentação é assunto próprio: entrada, cancelamento e saída para o PDV têm regra de saldo |
| `clonar` | copia em lote entre unidades; é operação de franqueadora, não de catálogo |
| tags (`ProdutosTagsController`) | `configuracoes/produtosTags`, outra permissão |
| pacotes | `configuracoes/pacotes`, outra permissão |

---

## O `catalogo` é obrigatório, e isso é deliberado

Uma lista unificada — tudo que se vende, num pedido só — seria útil e **seria
consulta nova**. O ERP não tem essa lista; construí-la aqui seria invenção desta
fatia, não migração. O parâmetro obrigatório mantém a API dizendo exatamente o
que o sistema sabe dizer hoje.

Quando o front novo precisar da lista única, ela entra como decisão consciente,
com o seu próprio `ORDER BY` e a sua própria paginação.

---

## O que vai ao serviço é escolhido campo a campo

`ProdutoService::update()` faz:

```php
(new ClassMethodsHydrator())->hydrate($data, $produto);
```

Ele chama **um setter para cada chave que receber**. A tela do ERP só não se
machuca porque o formulário limita o que é postado — a proteção está na tela,
não no serviço.

Repassar `$this->corpo()` cru daria ao pedido o poder de gravar qualquer coluna
da entidade, `unidade` e `status` inclusive. O controller monta o arranjo campo
a campo, e o que não está na lista não chega lá.

**E o `tipo` não muda depois de criado.** Trocar `ser` por `probar` mudaria a
base de cálculo de comissão, royalty e pontuação de todas as vendas passadas
daquele item, sem tocar em nenhuma delas. A tela do ERP também não deixa — ela
recusa editar um item cujo tipo não é do seu grupo.

---

## O defeito que a leitura achou (D125)

Escrever o `POST` obrigou a ler o construtor da entidade, e ele fazia isto:

```php
public function __construct(array $options = array()) {
    (new ClassMethodsHydrator())->hydrate($options, $this);   // grava
    ...
    $this->visivelApp = false;                                // e apaga
    $this->linhaVip = false;
    $this->insumo = false;
}
```

Hidratava, e **depois** sobrescrevia seis campos com o padrão. `insumo` e
`linha_vip` são dois selects renderizados na tela de produto novo: o operador
marcava, salvava, e o produto nascia com os dois falsos — sem erro, sem aviso.

O tamanho do contorno, medido na cópia de produção:

| campo | produtos que o têm | de 9.507 |
|---|---:|---:|
| `visivel_app` | 6.344 | 67% |
| `area` | 6.775 | 71% |
| `tag` | 1.656 | 17% |
| `linha_vip` | 1.637 | 17% |
| `periodos ≠ 1` | 543 | 6% |
| `insumo` | 50 | 0,5% |

Todos esses precisaram de **dois passos** — criar, depois editar — e ninguém
sabia por quê. Corrigido movendo os padrões para antes da hidratação;
`dataCriacao` e `pacoteItens` continuam depois, porque não são dado de
formulário.

O `clonar()` não era afetado: ele usa `clone`, não o construtor. O único caminho
que chamava `new Produto($data)` era o `insert`.

---

## O portão me corrigiu, e eu encurtei a cadeia

`conferir-acl-da-api.php` reprovou `ativarAction` e `desativarAction`: elas
delegavam a guarda por `mudarEstado()`, que delegava de novo a `produtoDaRota()`
— **dois níveis**, e o portão segue um só, de propósito.

A resposta certa era encurtar a cadeia, não afrouxar o portão. `mudarEstado()`
passou a conferir a permissão ela mesma.

---

## 27/09/2026 -- a tela nova do catálogo (fatia 1: consultar), com o `PUT` consertado antes

"Produtos" e "Serviços", no menu de Configurações, deixaram de abrir a tela
antiga: **as duas entradas abrem a mesma tela nova, cada uma no seu catálogo** --
como o recurso da API, que é um só. A lista pede os ativos, com busca por nome,
tipo, categoria e situação, e nos produtos o "estoque menor que" da tela antiga,
com as mesmas opções; a página é de 20. Cada item abre o cadastro em leitura: o
preço, o tipo, a categoria, a unidade; nos serviços os períodos, o "visível no
aplicativo", a área e a etiqueta; nos produtos o estoque, a linha VIP e o insumo.
**Cadastrar, alterar e movimentar o estoque continuam na tela antiga**, em outra
aba, para quem escreve -- e o cadastro aberto tem "atualizar", para quem salvou
lá e voltou.

### O servidor, antes da tela

A leitura para montar a tela achou o `PUT` quebrado, e a revisão achou o `POST`:

- **D235 -- o `PUT` de produtos e o de pacotes chamavam `update()`**, que o D202
  tinha trocado por `atualizar()` em 26/09 conferindo só a fiação do ERP. Erro
  fatal depois das guardas. Agora o de produtos vai ao `atualizar()` da tela do
  tipo, e o corpo passa por `RegraDoCatalogo`: **o que não cabe é 422 com o campo
  nomeado, e não 200 sem gravar**.
- **D239 -- o `POST` nunca gravou**, de produto, serviço nem pacote: o `status` é
  NOT NULL sem padrão, e ninguém o dava. O item nasce ativo, como no formulário.
- **D238 -- o recurso dizia o estoque de serviço**, e o PDV o mostrava ao lado de
  um corte: o tipo decide, pela regra da entidade.
- **D236 -- a listagem de produtos e a de serviços do ERP** aceitavam a unidade
  da query; e o portão que devia vigiá-las **ficava verde com a guarda
  arrancada**, em seis entradas, quatro delas de antes. O portão passou a exigir a
  marca uma vez só, ancorada.

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

28 agentes, nenhum morto; **23 achados, nenhum refutado**. Os que mais pesaram:

- **o meu ensaio não devolvia o que fotografava**: a `fn` do PHP captura por
  valor, e o `&` de dentro prendia a cópia. A primeira rodada passava e deixava o
  serviço, o produto e o pacote alterados no banco de contrato; a segunda sairia
  vermelha. Agora a foto é a do molde dos clientes;
- **o `POST` quebrado** (D239) e **o silêncio mudado de lugar**: a primeira regra
  recusava o campo do outro catálogo e calava área, etiqueta e estado. Agora o
  que a API não altera também é 422;
- **o `PUT` de pacotes, agora vivo**, gravava 0,00 para o valor 0.004, dava 500
  para o nome longo e a validade enorme, e trocava a validade nula por zero;
- **o insumo** mostrava "Insumo: não" e aceitava os campos que o ERP esconde
  dele; **o preço de compra** aparecia para quem só lê -- no ERP, só no
  formulário de quem escreve;
- **a categoria** era opcional e o valor de compra obrigatório -- o contrário do
  ERP;
- **o foco** caía no vazio quando a lista relida não tinha mais o item; e a
  busca de cinco colunas ficava com filtros de ~66px no tablet -- agora ela se
  arruma sozinha, sem ponto de quebra;
- **três testes passavam com o comportamento arrancado**: a linha VIP ia ao valor
  que a fixture já tinha, o caso do Console passava sem a `key` do destino, e
  nada provava que `status` e `unidade` do corpo sumiam.

**O que ficou anotado, e não é desta fatia:** o link de paginação das listagens
do ERP repete a query sem escape (**D240**), e o administrador, que na lista
antiga escolhe a unidade, na nova vê a da sessão.

**E o que desenhar a segunda fatia achou antes de começar (D241):** o `POST` que o
D239 fez gravar cadastra o serviço **sem área e sem etiqueta**, que no ERP são
obrigatórias -- e serviço sem área não aparece na agenda nem no aplicativo. Sem
consumidor ainda: a tela nova só lê. É o primeiro passo da fatia de escrita.

### Os mutantes

89, todos mortos, cada um pelo caso que devia: **portão, 8** (as marcas do recorte
por unidade, arrancadas uma a uma); **servidor, 33** (a regra e o `ProdutoJson`),
pela caracterização; **controller, 16** (o `PUT` sempre pelo serviço de produto e
sempre pelo de serviço, a regra arrancada do `PUT` e do `POST`, a regra com o tipo
errado, o cadastro sem `status`, o filtro de estoque nos serviços e fora da lista,
o `PUT` de pacotes de volta ao `update()`, a validade ausente virando zero, o valor
que arredonda, o emoji, o teto da validade), pelo ensaio de recusas; **front, 32**.

Um sobreviveu no caminho, e tirou código em vez de acrescentar teste: o efeito de
foco de cada linha. O foco ao voltar já era do efeito da tela, que acha a linha
pelo `data-item` -- arrancar o da linha passava verde, então ele não guardava
nada, e saiu.

**Os de controller pediram um instrumento.** Cada um rodava o ensaio de recusas
inteiro -- 15 a 25 minutos com a máquina carregada, seis horas para dezesseis, com
o Docker preso. O ensaio ganhou a rodada parcial, `ENSAIO_SO=84-101`: só os casos
escolhidos, 100 s, e **saída 3 mesmo com todos certos** -- quem esquecer a
variável no ambiente de um portão vê vermelho, e não um verde que mediu dezoito de
cento e quarenta e sete. E o limite de cada pedido passou a aceitar
`ENSAIO_TEMPO_LIMITE`, com 30 s de padrão: com o disco do contêiner lento, pedidos
triviais pararam até 19 s e derrubavam a rodada no meio.

### No navegador

Contra o banco de contrato, com dois usuários da fixture: o gerente, com o
privilégio de escrever no catálogo, e o colaborador, só com o de ler. As duas
entradas do menu de Configurações abrem a tela nova, cada uma no seu catálogo. Nos
produtos, "Barbearia" pede `tipo=probar`; "menor que 5" devolve zero, porque os
produtos da fixture nunca foram contados -- e na tela antiga também não entram. O
cadastro abre com o foco no título; o preço de compra e os dois caminhos para a
tela antiga aparecem para o gerente e não para o colaborador, que também não vê
"cadastrar produto novo". Voltar devolve o foco à linha de onde saiu. Os serviços
não têm a coluna nem o caminho do estoque, e o cadastro mostra os períodos, o
aplicativo, a área e a etiqueta. Em 375 px, no tema escuro, a página não rola de
lado: a tabela rola dentro do cartão (344 de 450 px).

**O que o navegador achou.** Nesta fatia, um detalhe que vai na seguinte: "nunca
contado" saía na fonte de número, alinhado à direita, como se fosse um valor. Fora
dela, o **D242**: o cabeçalho das tabelas, em `--tinta-3`, mede 3,62:1 no tema
escuro -- e o token é o de 51 regras.

**Provado:** a caracterização nos dois bancos (985 cada); o contrato do aplicativo,
67 nos dois; os oito ensaios HTTP no MySQL e os nove no PostgreSQL, com o de recusas
em 147 de 147 nos dois (18 casos novos) -- e, no MySQL, duas rodadas seguidas com o
catálogo igual antes e depois; os portões (48 verdes e o D50, o vermelho conhecido);
799 testes no front, build e lint; 89 mutantes, todos mortos; e o navegador contra o
banco de contrato.

**Uma rodada dos portões saiu vermelha no front, e a seguinte não.** Nada do front
mudou entre as duas; é a família dos testes de arrastar da agenda que falham às
vezes com a máquina carregada -- já em tarefa separada. Anotado aqui para que o
vermelho não suma do registro por ter passado na segunda.

## 27/09/2026 -- as fatias 2 e 3: cadastrar, alterar, desativar, e o estoque

A tela do catálogo passa a escrever. **Fatia 2:** cadastrar produto e serviço,
alterar, desativar e ativar -- para quem tem o privilégio de escrever no
catálogo. **Fatia 3:** o estoque do produto, no lugar da tela antiga
`produtos/estoque`: o saldo, o lançamento de entrada e de saída, e o cancelamento
da movimentação.

### O servidor, antes da tela

- **D241, o serviço sem área e sem etiqueta.** O `POST` cadastrava o serviço sem as
  duas, que no ERP são obrigatórias -- e o serviço sem área não aparece na agenda
  nem no aplicativo. Agora são obrigatórias no cadastro, conferidas contra as
  ativas, e o cadastro vai pelo `ServicoService`. `GET /adm/api/produtos/catalogos`
  dá as duas listas ao formulário.
- **D243, a movimentação sem data.** Achado no navegador: toda linha do estoque
  dizia "—", porque o getter da entidade devolvia texto e o `ProdutoJson` só
  converte instante.

### O formulário

O formulário é um só, para o cadastro e para a alteração, e segue o do ERP campo a
campo: o tipo só se escolhe no cadastro, e não muda depois (é a base de comissão,
royalty e pontuação de toda venda do item); o serviço tem área, etiqueta, períodos
e aplicativo; o produto, linha VIP e insumo -- menos o insumo, que não tem os dois;
e o estoque inicial só no cadastro. **O `PUT` leva só o que mudou**: mandar o
resto de volta desfaria o que outra aba acabou de salvar. A área ou a etiqueta
desativada depois continua no `<select>` do item que a tem, marcada "(inativa)":
um `<select>` cujo valor não é opção mostra a primeira e guarda outra.

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

Seis revisores, um por dimensão (o servidor do D241, o formulário, o estado e as
corridas, o estoque, a acessibilidade e o CSS, os testes), e um cético para cada
achado. **43 achados, 42 confirmados, 1 refutado, nenhum sem veredito.** O que
mudou:

- **O dinheiro sem teto** (servidor): "1" seguido de trezentos e nove zeros passava
  pela conferência do decimal e virava INF. O catálogo e o pacote passam a recusar
  acima de 999.999,99, o maior que cabe no campo do ERP. **Medido com o teto
  arrancado:** no MySQL, o `PUT` do serviço com o preço sem fim respondia **500**; o
  do pacote respondia **200** -- aceitava. (No PostgreSQL, `'INF'::float8` é
  `Infinity`: o texto que o PHP produz para o infinito vira número lá.)
- **O produto sem quantidade** nascia nulo, e no ERP nasce zero -- e o nulo sai do
  filtro "estoque menor que". O servidor dá zero, e o formulário começa em `0` e
  exige o campo.
- **A compra apagada na alteração** não ia no `PUT`: a tela dizia "Cadastro salvo."
  com o preço de antes. Agora vai zero, como o ERP grava.
- **O estoque aberto a quem só lê** (a tela do ERP exige escrever), **a movimentação
  devolvida pelo `POST`** (a primeira da listagem, e não a criada -- duas no mesmo
  segundo empatavam) e **o lançamento sem teto**.
- **O foco no `body`**, em cinco caminhos: o "não" da confirmação, que sai da tela
  apertado; o cancelamento recusado, cuja linha relida perde o botão; a releitura
  que falha e desmonta a tela; e o produto cadastrado depois de uma ida ao estoque,
  que abria com o foco no botão "estoque" -- e não anunciava o aviso.
- **O saldo que volta ao cadastro** era o da última gravação: quem só abriu o
  estoque, com outra aba tendo lançado, voltava com o saldo velho. Agora é o mais
  novo que o servidor disse, lido ou gravado; e a gravação relê, por baixo, a data
  de alteração do produto.
- **O duplo clique em "cancelar"** confirmava sem ninguém ler a pergunta -- e o
  cancelamento não se desfaz. O Enter seguro, também.
- **O 409 da saída maior que o saldo** não relia: o cartão dizia 10 e a frase do
  servidor, 2.
- **A recusa do cancelamento** ficava na tela depois de virar a página; **a página
  que já não existe** aparecia como "página 3 de 2"; **os botões de cancelar** de
  duas entradas iguais tinham o mesmo nome -- agora levam a data; e **o vermelho do
  "confirmar"** sumia no hover.
- **Os testes que mediam nada:** a etiqueta e a área da fixture tinham o mesmo id,
  e trocar uma lista pela outra passava verde; a asserção "serviço não tem estoque"
  lia um JSON que é sempre nulo; e onze caminhos não tinham caso -- voltar e
  desistir durante a carga das áreas, o aplicativo e a linha VIP em um valor só, o
  aviso que vazaria ao próximo item, a categoria fora da lista, o cancelamento fora
  da página 1.

**O refutado** saiu refutado por maioria, com um cético a favor: que o cadastro,
ao voltar, recebe o saldo da última gravação, e não o que o estoque acabou de ler.
É o mesmo caminho do achado confirmado sobre o saldo, dito de outra forma -- e o
conserto dele o cobre.

**E um do mapa da fase seguinte, que é desta fatia:** a lista que encolhe ao virar
a página -- outra aba desativou um item, e o que cabia em duas páginas cabe em uma
-- tira a paginação com o botão apertado dentro. O foco vai ao título, no catálogo
e no estoque. As clientes, as vendas realizadas e os devedores têm a mesma
lacuna, e são consertados num passo próprio.

### Os mutantes

**108, todos mortos**: **front, 80** (a tela, o invólucro, o estoque e a lógica pura
do formulário); **servidor, 16** (a regra do D241, o teto, o zero do produto e a
data da movimentação), pela caracterização; **controller, 12**, pelo ensaio de
recusas por HTTP. Três coisas que a rodada ensinou:

- **O morto precisa dizer quem o matou.** Com a máquina carregada, um caso que
  estoura o tempo também sai vermelho, e o arnês contaria isso como morte. O arnês
  do front passou a imprimir o caso que falhou, e a rodada dos mutantes novos saiu
  com o nome de cada assassino -- o caso escrito para ele.
- **Uma regra nova deixou dois vivos.** Depois da primeira rodada, a leitura que
  derruba o foco passou a mandá-lo ao título -- e isso cobria, depois da releitura,
  o foco que o cancelamento dá ao título na resposta. Os dois mutantes que o
  arrancavam ficaram verdes, porque o caso só olhava o fim. O caso passou a segurar
  a releitura e conferir o foco **antes** dela, que é quando o aviso tem de ser
  lido.
- **Dois mutantes que se cobriam.** O `is_int` e o `in_array` estrito recusavam os
  mesmos valores -- arrancar um deixava o outro de guarda, e os dois ficavam verdes.
  O `is_int` saiu; o estrito ficou, e o seu mutante morreu no caso que manda o id em
  texto.
- **Os do controller mediram o antes.** Com a guarda de volta a ler, o colaborador
  que só lê via o estoque (200); sem o teto, o preço sem fim dava 500 no serviço e
  200 no pacote; sem o zero, o produto nascia sem quantidade (201).

### No navegador

Contra o banco de contrato, como o gerente da fixture -- que no banco montado do
zero não tem o privilégio do catálogo: o ensaio o concede e o tira. Ele foi dado à
mão, no banco de contrato, e a sessão aberta de novo.

O cadastro do serviço sem área e sem etiqueta marca as duas, conta "2 campos
precisam de ajuste." e leva o foco à área; com as duas, o `POST` responde 201 e o
detalhe abre com o foco no título, que anuncia "Serviço cadastrado.", com a área, a
etiqueta e a compra em R$ 0,00. No estoque da pomada, a entrada de 5 relê a lista e
diz o saldo; as duas entradas de 5 -- uma da fixture, outra de agora -- têm botões
com nomes diferentes, pela data. O "não" devolve o foco ao botão que perguntou; o
duplo clique só pergunta, sem `POST`; o "confirmar" continua vermelho no hover
(`#A8341F`, o `--perigo`); confirmar leva o foco ao título **antes** da releitura,
com o aviso na descrição, e a releitura marca a linha "cancelada" com o motivo.
Voltar leva o foco ao botão do estoque, e a data de alteração chega por baixo, sem
tirar o foco dele. Em 375 px, claro e escuro, a página não rola de lado: a tabela
rola dentro do cartão.

**O que o navegador achou:** o produto **nunca contado** passava a "0" só de abrir o
estoque e voltar. A API do estoque devolve `(int)` da coluna nula -- zero --, e o
conserto do saldo que volta ao cadastro tomava esse zero por uma mudança. O saldo
agora é comparado com `quantidade ?? 0`, com o caso e o mutante.

**Provado:** a caracterização nos dois bancos (990 cada); o contrato do aplicativo,
67 nos dois; os oito ensaios HTTP no MySQL e os nove no PostgreSQL, com o de recusas
em 160 de 160 nos dois (13 casos novos: do 102 ao 114); os portões (48 verdes e o
D50, o vermelho conhecido -- depois de três vermelhos que eram desta fatia: a marca
das fixtures não regravada, a contagem de chamadas do módulo, que foi de 129 a 132, e
dois pares de débitos com palavras em comum, anotados com o motivo); 918 testes no
front, build e lint; 108 mutantes, todos mortos; e o navegador contra o banco de
contrato.
