# Fase 2 — a décima fatia: as unidades

Escolhida em 08/09/2026. A nona fatia deixou a dívida escrita na própria tabela
de recorte: *"`unidadesConveniadas` — mesma coisa do lado das unidades; entra
quando houver recurso de unidade"*. Não havia. E sem ele, sete fatias falam de
unidade — o PDV, o caixa, a agenda, o estoque, os usuários — e nenhuma sabe
dizer **que unidade é essa**.

---

## O recorte

| endpoint | verbo | origem |
|---|---|---|
| `/adm/api/unidades` | `GET` | `indexAction` |
| `/adm/api/unidades` | `POST` | `novoAction` → `UnidadeService::insert` |
| `/adm/api/unidades/:id` | `GET` | `editarAction`, leitura |
| `/adm/api/unidades/:id` | `PUT` | `editarAction` → `update` |
| `/adm/api/unidades/:id/socios` | `GET` | `tabelaSociosHtml` → `selecionarSocios` |
| `/adm/api/unidades/:id/ativacao` | `POST` | `ativarRemover` |
| `/adm/api/unidades/:id/desativacao` | `POST` | idem |

**Fica de fora:**

| o quê | por quê |
|---|---|
| `mensageria` | é a configuração de mensagens da unidade, com texto por evento; recorte próprio |
| `selecionarHorarios` | não é da tela de unidades — é o botão "copiar horários" da tela de **usuários** (ver D131) |
| `localizarAcessos` | é relatório de acessos, com o seu próprio recorte e paginação |
| escrita de sócio | `socioSalvar` e `socioRemover` são escrita em sub-recurso; a leitura entra agora, a escrita quando houver tela |

---

## Duas coisas se chamam `estado`, e uma delas tinha de mudar de nome

As nove fatias anteriores usam `estado` para uma coisa só — `"ativo"` ou
`"inativo"` — e são 55 endpoints com esse acordo. A tabela `unidades` tem uma
coluna chamada `estado` que quer dizer **a sigla do estado brasileiro**.

Manter as duas leituras no mesmo recurso é o tipo de ambiguidade que ninguém
percebe até alguém filtrar `?estado=ativo` e receber unidades de um estado
chamado "ativo". Então:

- **`estado`** continua sendo o ciclo de vida, como nas outras nove;
- **`uf`** é a sigla — que é como se diz no Brasil quando precisa ser sem dúvida.

A tradução acontece na fronteira do controller (`'estado' => fromQuery('uf')`) e
não vaza para nenhum dos dois lados.

---

## O interruptor, de novo — e a mesma resposta

```php
public function ativarRemover($unidade) {
    $unidade->setStatus($unidade->getStatus() ? false : true);
```

Idêntico ao dos convênios. Dois endereços idempotentes, cada um conferindo o
estado **antes** e recusando com `409` quando já está como se pede. A segunda
chamada de `POST .../ativacao` não faz o oposto do nome dela.

---

## Cinco colunas que o recurso não devolve

`unidades` tem **72 colunas**. Cinco não saem.

Eram seis, sobre 73 colunas, quando esta fatia foi escrita: a sexta era
`iframe_bi`, a URL do painel do Power BI. Ela saiu do sistema em 09/09/2026,
com o Power BI inteiro.

| coluna | por quê |
|---|---|
| `whatsapp_api_key` | credencial: quem a tem manda mensagem em nome da barbearia |
| `whatsapp_instancia` | a outra metade do par; um recurso de leitura não entrega meia credencial |
| `hash` (e `hash_anterior`) | é por onde a unidade se identifica em integração — comparado com `hash_equals` desde o D77, o que só faz sentido se for segredo |
| `asaas_id` | identificador no gateway de pagamento |
| `faturamento_socio_adm_cpf` | dado pessoal; o **nome** do sócio administrador sai, o CPF não |

É a regra da oitava fatia aplicada a uma entidade muito mais rica. E o teste
verifica com o cenário **cheio**: a unidade de fixture tem os cinco campos
preenchidos, porque um cenário com nulos passaria por ausência de dado.

Também ficam de fora `relatorio_atendimentos` e
`relatorio_dashboard_colaboradores` — carimbos de controle das rotinas de cron,
não estado da unidade.

---

## `applyMask` apaga a diferença entre "não apurado" e "zero"

```php
public static function applyMask($valor, $pais = null) {
    return !empty($valor) ? number_format($valor, 2, ',', '.') : "0,00";
}
```

`applyMask(null)` e `applyMask(0)` devolvem **o mesmo texto**. E cinco getters
da unidade só sabiam devolver a máscara: os quatro `royltes*` e
`potencialFranquia`.

Duas consequências, e as duas importam para a API:

1. `"1.234,50"` não é número em JSON nenhum, e desfazer isso passaria por
   `Monetario::removeMask()` — que apaga o ponto por ser separador de **milhar**.
2. `potencial_franquia` e `nps` são colunas **anuláveis**. Pela máscara, "não
   apurado" e "zero" viram a mesma resposta.

Os cinco getters ganharam `$mascara = true`, o mesmo argumento que
`Produto::getValorVenda()` já tinha desde a sexta fatia. Toda a tela do ERP fica
como estava; quem precisa do número pede.

O cenário do teste põe `potencial_franquia` **nulo** e `reducao_potencial`
**zero** lado a lado, de propósito. Sem os dois juntos, um recurso que
confundisse os dois passaria verde. E não é hipótese de laboratório: das oito
primeiras unidades da cópia de produção, **três têm potencial nulo** e as outras
cinco têm valor.

---

## Nulo quer dizer fechado

Domingo sem horário é o caso comum. Um recurso que devolvesse `"00:00"` no lugar
do nulo diria que a loja abre à meia-noite — e a agenda acreditaria.

Sobre os segundos: `DataHora::stringToTime()` usa
`createFromFormat("H:i", $time)`, e a suspeita era de que os segundos não
informados viessem do relógio. **Medido, e não era o caso**: o PHP zera os
segundos quando o formato traz hora e minuto, e a base confirma — 63 unidades e
2.431 usuários, nenhum segundo diferente de zero em nenhum dos campos de
horário. Hipótese descartada por medição, não por argumento.

---

## `POST` cobra dez campos, e não inventa nenhum

`unidades` tem **20 colunas `NOT NULL` sem valor padrão**. Cinco o construtor
passou a preencher (D130), `hash` e `data_criacao` ele gera. Sobram treze.

Três a API resolve sozinha, cada uma por um motivo:

- **`status`** — unidade nasce ativa; o ciclo de vida tem endereço próprio.
- **`visivel_app`** — nasce falso: aparecer no aplicativo do cliente é decisão de
  quem revisa, depois.
- **`whatsapp_ativo`** — nasce falso, e aqui não há escolha honesta: a chave do
  provedor não entra por esta API, então a unidade criada aqui **não tem** como
  mandar mensagem. Dizer `true` seria afirmar uma capacidade inexistente.

As dez restantes são cobradas pelo nome (regra 3). **Nenhuma ganha padrão** — e
a tentação era grande: 60 das 63 unidades usam 90 dias e 2 visitas de
fidelização. Mas fidelização é política comercial, e escolher política por
omissão para uma unidade nova é decidir no lugar de quem decide.

---

## Os dois defeitos que a leitura achou

### D130 — o construtor descartava o que o formulário mandava

```php
public function __construct(array $options = array()) {
    (new ClassMethodsHydrator())->hydrate($options, $this);
    ...
    $this->cashback = 1;            // <- apaga o que acabou de chegar
```

Seis atribuições sem condição, logo depois da hidratação. **Não era hipotético**:
`Form/Unidade.php` manda `cashback`, `reducao_potencial`, `link_boas_vindas` e
`agenda_primeiro_nome`, e os quatro chegavam ao construtor para morrer nas linhas
seguintes.

Medido com um POST sintético: **cinco enviados, cinco descartados**. Depois da
correção, **zero**. O quinto era `id_yungas`, e ele escapava por acidente —
`UnidadeService::insert()` o define outra vez, depois de construir. Campo salvo
por uma segunda atribuição em outro arquivo não é campo seguro.

É o D125 de novo, na `Produto`, encontrado na sexta fatia.

### D131 — cinco ações da tela de unidades não conferiam privilégio

E não é só a tela de unidades. O sistema **não tem guarda global de
privilégio**: o único listener de `EVENT_DISPATCH` que decide alguma coisa
pergunta *"há sessão?"*. Quem responde por privilégio é cada ação, chamando
`getAcesso()` no próprio corpo.

    296 ações na superfície do navegador
    130 não chamam nenhuma das duas

As cinco de `UnidadesController` foram corrigidas, e elas ilustram por que as
outras 130 não podem ser corrigidas em varredura:

| ação | guarda | quem chama |
|---|---|---|
| `localizarAcessos` | `unidades`/`ler` | o index de unidades |
| `socioSalvar` | `unidades`/`escrever` | a tela de editar unidade |
| `socioPesquisar` | `unidades`/`escrever` | idem |
| `socioRemover` | `unidades`/`escrever` | idem |
| **`selecionarHorarios`** | **`usuarios`/`escrever`** | a tela de editar **usuário** |

Uma varredura que aplicasse `getAcesso()` em todas teria fechado a quinta porta
na cara de quem tem direito. O privilégio certo é o da **tela que chama**, e
descobrir isso é ler o JavaScript que faz o POST.

A pior das cinco era `socioPesquisar`: devolvia o cadastro inteiro de um sócio
por id — CPF, data de nascimento, telefone, e-mail e endereço completo — e é a
única que só lê, o que permitia varrer os ids em sequência sem deixar rastro de
escrita.

O resto virou `scripts/conferir-guarda-do-erp.php`, com a **lista congelada** das
130. Ele falha nos dois sentidos: ação nova sem guarda é vermelho, e ação da
lista que ganhou guarda também — pedindo para tirar da lista.

**A lista é o que pega, e não o número.** No ensaio vermelho, tirar uma guarda e
pôr outra manteve o total em 130 nas duas medições. Um portão que contasse teria
passado.

---

## O que o recurso de sócios herda de graça

```php
$dql = "SELECT soc.id, soc.nome, soc.email, soc.telefone" ...
```

`selecionarSocios()` já é estreita: quatro campos, e nada mais. CPF, data de
nascimento e endereço existem na tabela e não passam por ali.

Não é sorte — a lista da tela nunca precisou deles. O recurso herda o recorte em
vez de montar uma consulta nova e mais larga, que seria o jeito mais fácil de
vazar sem querer. O cenário do teste tem CPF e endereço preenchidos nos dois
sócios justamente para que a ausência signifique alguma coisa.
