# Fase 2 — a sétima fatia: o estoque

Escolhida em 08/09/2026, e ela já estava escolhida: a sexta deixou o estoque de
fora com o motivo escrito — *"movimentação é assunto próprio: entrada,
cancelamento e saída para o PDV têm regra de saldo"*.

Ver `docs/f2-fatia-catalogo.md` para o recorte que a antecedeu.

---

## Três recursos, sub-recursos do produto

| endpoint | verbo | origem |
|---|---|---|
| `/adm/api/produtos/:id/estoque` | `GET` | `estoqueAction`, a listagem |
| `/adm/api/produtos/:id/estoque` | `POST` | `estoqueAtualizar` |
| `/adm/api/produtos/:id/estoque/:movimentacao/cancelamento` | `POST` | `estoqueCancelar` |

Estoque não existe sozinho — é sempre **de** um item —, então a rota é a que já
carrega o `:id` do produto.

**Fica de fora** a `retirada` (e a `retiradaPdv`): ela é a saída para uso do
barbeiro, com `solicitadoPor` — quem pediu é outra pessoa que quem registrou. É
fluxo do PDV, e entra com ele.

---

## O verbo, e por que cancelar não é `DELETE`

`estoqueCancelar()` faz `setStatus(false)`, grava um motivo em texto e
**reverte** a quantidade no produto. A linha continua na lista, e precisa
continuar: ela é o registro de que houve uma entrada e de que ela foi desfeita.

`DELETE` prometeria um sumiço que não acontece — e apagaria justamente a
informação que o estoque de amanhã precisa para explicar o de ontem.

---

## Duas traduções na fronteira

**1. A operação sai por extenso.** O banco guarda `ent` e `sai`, três letras que
só quem conhece o sistema lê. A API recebe e devolve `entrada` e `saida` — três
letras num corpo JSON é o tipo de coisa que quem integra erra uma vez e descobre
em produção.

**2. O sinal vai separado do número.** A coluna `quantidade` é sempre positiva;
quem decide somar ou subtrair é a operação. O recurso entrega `quantidade: 2` e
`efeito: -2`, para que quem monta a tela não refaça a conta — e para que dois
lugares não a refaçam diferente.

E **o saldo vai junto da lista**, porque a lista não permite reconstruí-lo:
movimentação cancelada continua nela, com a quantidade original, e somar tudo
daria outro número.

---

## O piso do estoque é conferido no controller, e sempre foi

```php
if ($request['operacao'] == "ent"
    || ($request['operacao'] == "sai" && $request['quantidade'] <= $produto->getQuantidade())) {
```

`ProdutoService::estoqueAtualizar()` subtrai sem perguntar — ele iria a negativo
sem reclamar. Quem impede é a tela, com esse `if` antes de chamar.

A API repete a conferência, porque a regra é dela também. E o `409` diz **quanto
há**, para o operador não precisar adivinhar quanto cabe.

---

## O defeito que a leitura achou (D126)

`estoqueCancelarAction` fazia isto, e mais nada:

```php
$movimentacao = $this->em->getRepository(ProdutoEstoque::class)->find($this->params()->fromRoute('id', 0));

if (!empty($movimentacao)) {
    $this->sm->get(ProdutoService::class)->estoqueCancelar($movimentacao);
```

**Faltavam duas guardas**, e as duas importam porque cancelar **mexe no
estoque**:

**1. A unidade.** A ação irmã, `estoqueAction`, passa por `selecionarProduto()`,
que confere `unidade == $this->unidadeId || $usuarioAdmin`. Esta não passava por
nada. São **33.573 movimentações com id sequencial, em 51 unidades**: qualquer
usuário com permissão de produtos cancelava a movimentação de qualquer unidade
trocando o número na URL.

**2. O estado.** Cancelar reverte a quantidade; cancelar **duas vezes** reverte
duas. Só o template impedia — ele esconde o botão quando `getStatus()` é falso
(`estoque.phtml:107`) —, e o botão não é a única porta. Há **715 movimentações
já canceladas** na base, cada uma um alvo.

As duas entraram na tela do ERP no mesmo dia, e a API nasce com elas fechadas.

---

## O que a fatia não resolve

`ProdutoService::estoqueAtualizar()` continua aceitando ir a negativo se alguém
o chamar direto — a guarda está em quem chama, não nele. Mover a regra para o
serviço é o conserto certo e mexe no caminho da tela do ERP; fica anotado, e não
foi feito aqui para a fatia não mudar o comportamento que está caracterizando.
