# Correção: unicidade de login/CPF em Profissional (login vazio e reuso após exclusão)

**Data:** 2026-07-14
**Área:** Cadastro de Profissional
**Arquivos-alvo:** `models/Profissional.php`, `controllers/ProfissionalController.php`, nova migration

## Problema

Ao cadastrar profissionais, dois sintomas ligados à mesma causa:

1. **Login vazio duplicado quebra o cadastro.** A coluna `login` é nullable e tem índice único `uq-profissional-login`. Login é opcional (só perfis com acesso — `lider`, `gerente`, `administrativo` — precisam). No MySQL, um índice `UNIQUE` **permite múltiplos `NULL`, mas NÃO permite múltiplos `''` (string vazia)**. O formulário grava `''` quando o campo vem em branco, então o **primeiro** profissional sem login entra (grava `''`) e o **segundo** colide no índice.

2. **A tela de erro 500 estoura em vez de mostrar mensagem no input.** O validador `[['login'], 'unique']` tem `skipOnEmpty = true` (padrão do Yii) e **ignora** o valor vazio — a validação passa. Sem erro de validação, `save()` prossegue para o `INSERT`, e o índice único do MySQL lança `yii\db\IntegrityException` (exceção de banco, não erro de validação). A exceção sobe sem tratamento e o Yii renderiza a página de erro 500.

**Causa raiz comum:** o conflito de unicidade só é detectado no banco (tarde demais para virar mensagem amigável), porque o valor problemático (`''`) escapa do validador.

### Problema correlato: reuso após soft-delete

O soft-delete (`BaseActiveRecord::delete()`) apenas seta `deleted_at`; a linha permanece no banco. O índice único do MySQL abrange **todas** as linhas, inclusive as excluídas, enquanto o validador `unique` do Yii usa `find()` (que filtra `deleted_at IS NULL`). Consequência: tentar recadastrar um **login ou CPF que pertence a um profissional excluído** passa na validação (o validador não "vê" o registro excluído) e estoura no índice único — mesma explosão de tela 500. Isso vale tanto para `uq-profissional-login` quanto para `uq-profissional-cpf`.

## Objetivos

- Permitir múltiplos profissionais sem login.
- Permitir recadastrar login e CPF de profissionais que foram excluídos (soft-delete).
- Garantir que qualquer colisão de unicidade vire mensagem amigável no formulário, nunca tela 500.
- Manter a obrigatoriedade de login para perfis com acesso e de CPF em cadastros normais.

## Design

### 1. Normalizar login vazio → `NULL` (`models/Profissional.php`)

Sobrescrever `beforeValidate()`:

```php
public function beforeValidate()
{
    if (!parent::beforeValidate()) { // mantém a normalização monetária do BaseActiveRecord
        return false;
    }
    if (is_string($this->login) && trim($this->login) === '') {
        $this->login = null;
    }
    return true;
}
```

Com o login vindo como `null`, o índice único do MySQL passa a aceitar vários profissionais sem login (NULLs são isentos de unicidade). O validador `required` condicional (`when => precisaLogin()`) continua barrando `null` para perfis com acesso.

### 2. Liberar login e CPF no soft-delete (`models/Profissional.php`)

Sobrescrever `delete()` para que o registro excluído não reserve mais login nem CPF no índice único, persistindo tudo num único `save`:

```php
public function delete()
{
    // Libera login e CPF do índice único: registro excluído não deve reservar
    // esses valores, permitindo recadastro. O histórico fica na auditoria.
    $this->deleted_at = time();
    $this->login = null;
    $this->cpf = null;
    return $this->save(false, ['deleted_at', 'updated_at', 'login', 'cpf']) ? 1 : false;
}
```

`save(false, ...)` pula validação (não dispara os `required`), e o `AuditBehavior` registra a alteração. O CPF/login do registro excluído fica preservado apenas na `auditoria`.

### 3. Migration — cpf nullable + limpeza de dados

Nova migration `m260714_000001_fix_profissional_login_cpf_unique.php`:

```php
public function safeUp()
{
    // CPF precisa aceitar NULL para poder ser liberado no soft-delete.
    $this->alterColumn('{{%profissional}}', 'cpf', $this->string(14)->null());
    // Registros legados com login '' passam a NULL para não colidir no índice único.
    $this->update('{{%profissional}}', ['login' => null], ['login' => '']);
}

public function safeDown()
{
    // Reverter para NOT NULL só é possível se não houver cpf NULL (registros excluídos).
    $this->alterColumn('{{%profissional}}', 'cpf', $this->string(14)->notNull());
}
```

> Nota: `safeDown()` falhará se já houver profissional excluído com `cpf = NULL`. É um efeito esperado do modelo de soft-delete e está documentado aqui; a reversão é best-effort.

### 4. Defesa em profundidade no controller (`controllers/ProfissionalController.php`)

Envolver o `save()` em `try/catch` de `yii\db\IntegrityException` dentro de `salvar()`, traduzindo a colisão de índice único numa mensagem no input e re-renderizando o `_form`:

```php
private function salvar(Profissional $model)
{
    if ($model->load(Yii::$app->request->post())) {
        $perfis = Yii::$app->request->post('Profissional')['perfisArray'] ?? [];
        $model->perfis = is_array($perfis) ? implode(',', $perfis) : '';
        try {
            if ($model->save()) {
                Yii::$app->session->setFlash('success', 'Profissional salvo.');
                return $this->redirect(['index']);
            }
        } catch (\yii\db\IntegrityException $e) {
            $msg = $e->getMessage();
            if (stripos($msg, 'login') !== false) {
                $model->addError('login', 'Login já cadastrado.');
            } elseif (stripos($msg, 'cpf') !== false) {
                $model->addError('cpf', 'CPF já cadastrado.');
            } else {
                $model->addError('cpf', 'Registro já cadastrado.');
            }
        }
    }
    $model->perfisArray = $model->getPerfisLista();
    return $this->render('_form', ['model' => $model, 'funcoes' => Funcao::ativas()]);
}
```

A identificação do constraint é feita pelo nome do índice/coluna presente na mensagem da exceção (`uq-profissional-login` / `uq-profissional-cpf`). Esse caminho é uma rede de segurança para corridas de concorrência e casos residuais — o fluxo normal já é tratado pelas correções 1–3.

## Testes

**Unit (`models/Profissional`):**
- Dois profissionais com perfil `profissional` (sem login) salvam sem colidir; ambos com `login === null`.
- Perfil `lider` sem login → falha de validação com "Login é obrigatório para perfis com acesso.".
- `beforeValidate` converte `login = '   '` (espaços) em `null`.
- Excluir (`delete()`) um profissional zera `login` e `cpf` e mantém a linha com `deleted_at` preenchido.

**Functional (`ProfissionalController`):**
- Excluir um `lider` com login `joao` e cadastrar novo profissional com login `joao` → sucesso (login liberado).
- Excluir um profissional com CPF `X` e cadastrar novo com CPF `X` → sucesso (CPF liberado).
- Forçar colisão de índice único → resposta re-renderiza o formulário com erro no input (`login`/`cpf`), sem página 500.

## Fora de escopo

- Fluxo de "reativar" (un-delete) registro excluído em vez de criar duplicata.
- Aplicar o mesmo tratamento de unicidade a outras tabelas/telas.
- Tela de edição de permissões (não existe na v1.0).
