# Atendimento do CRM — canais, ciclo de vida e contenções

> Contexto consolidado em set/2026, a partir da atividade #479827 (documentar o CRM do admin).
> Serve como referência de entendimento do módulo para futuras implementações.

> Mecânicas relacionadas — **não duplicar aqui**:
> - Motor de IA do atendimento e Fluxo Construtor → [../FluxoConstrutor/ia-atendimento-e-motor-fluxo.md](../FluxoConstrutor/ia-atendimento-e-motor-fluxo.md)
> - Processos, trâmites e centro de custo → [../Unico/processos-centro-custo.md](../Unico/processos-centro-custo.md)
> - Estrutura de pedidos de marketplace → [../Marketplaces/fluxo-pedido.md](../Marketplaces/fluxo-pedido.md)

---

## O que é

Um **atendimento** é uma conversa entre a empresa e uma pessoa de fora, com dono, prazo e desfecho. Ele não é um chat: é uma unidade de trabalho que alguém precisa pegar, responder e encerrar.

Três coisas diferentes convivem sob o mesmo nome e é comum confundi-las:

- o **canal de entrada** (a "origem") — por onde a mensagem chegou: e-mail, site, WhatsApp, Messenger, uma pergunta de marketplace. Define quem atende, qual o prazo e como se responde de volta;
- o **atendimento** em si — o registro com situação, responsável, pedido vinculado e histórico;
- a **mensagem** — cada turno da conversa, que é onde vive o prazo real (SLA) e o anexo.

O que percorre o sistema é sempre o mesmo caminho:

```
canal → webhook/captura → atendimento (aberto) → fila → humano ou bot → tramitação → encerramento
```

Pontos de partida do código:

- `src/CRM/Model/Atendimento.php` — a entidade e as regras de ciclo de vida
- `src/CRM/Controller/AtendimentoController.php` — o orquestrador: abre, inicia, tramita, libera, finaliza
- `src/CRM/Repository/AtendimentoRepository.php` — fila, consultas e os indicadores de performance
- `src/CRM/Model/Webhook/AbstractWebhookAtendimentoBaseProcessor.php` — a porta de entrada dos canais conversacionais

---

## Canal de entrada (origem de atendimento)

A origem é um `bigint` gravado em `crm_atendimento.ate_origem`. Ela não é uma FK: é um identificador que vem de **duas fontes ao mesmo tempo**.

### As origens fixas

Constantes de `src/CRM/Model/AtendimentoOrigemInterface.php` — 39 valores, com os IDs 27 e 39 vagos por descontinuação. Agrupadas por família:

- **Site e loja própria**
  - `ORIGEM_SITE = 1`
  - `ORIGEM_SITE_ASSISTENCIA = 5`
  - `ORIGEM_COMENTARIO = 11`
- **E-mail (caixas monitoradas)**
  - `ORIGEM_EMAIL = 2`
  - `ORIGEM_EMAIL_LOGISTICA = 7`
  - `ORIGEM_EMAIL_LOGISTICA_TRANSPORTADORA = 8`
  - `ORIGEM_EMAIL_TROCA = 15`
  - `ORIGEM_EMAIL_RASTREIO = 16`
  - `ORIGEM_JURIDICO = 23`
  - `ORIGEM_CHARGEBACK = 9`
- **Mensageria direta**
  - `ORIGEM_WHATSAPP = 22`
  - `ORIGEM_META_MESSENGER = 25`
  - `ORIGEM_META_INSTAGRAM = 26`
- **Comentários de rede social**
  - `ORIGEM_META_COMENTARIO_INSTAGRAM = 40`
  - `ORIGEM_META_COMENTARIO_FACEBOOK = 41`
- **Marketplaces** — quase sempre em trio pedido / produto / reclamação
  - Mercado Livre: `3` produto, `4` pedido, `6` reclamação, `37` reclamação em mediação
  - Magazine Luiza: `10` pedido, `24` produto, `31` conversa
  - Shopee: `28` pergunta, `29` pedido, `30` produto
  - TikTok Shop: `32` pedido, `33` produto, `34` geral
  - Kabum: `18` pedido, `19` oferta
  - Outros: `12` Colombo, `17` Leroy Merlin, `21` Via Varejo, `35` Amazon manual, `36` MadeiraMadeira
  - Arquivados (não recebem mais): `13` e `14` SkyHub/B2W
- **Pós-venda transversal**
  - `ORIGEM_DEVOLUCAO_MARKETPLACE = 20`
  - `ORIGEM_POS_VENDA_PEDIDOS = 38`

### As origens customizadas

`Atendimento::getOrigemList()` soma às constantes o resultado de `OrigemAtendimentoConfiguracaoRepository::findCustomizadasList()` — toda linha de `crm.crm_origem_atendimento_configuracao` que tenha `orconf_nome` preenchido vira uma origem nova, sem deploy.

O id de uma origem customizada é gerado a partir do **timestamp Unix em segundos**, incrementando em caso de colisão. É isso que garante que ela nunca bata com as constantes fixas, que vivem na faixa 1–41.

> **Cuidado:** `getOrigemList()` guarda o resultado em cache estático de request (`$origemCustomCache`). Depois de criar uma origem nova é obrigatório chamar `Atendimento::clearOrigemListCache()`, senão ela só aparece no request seguinte.

### Configuração por origem

**Entidade:** `CRM\Model\OrigemAtendimentoConfiguracao` → `crm.crm_origem_atendimento_configuracao`

| Propriedade | Coluna | Observação |
|---|---|---|
| `origem` | `ate_origem` | PK. É o próprio id da origem, não um id sequencial |
| `nome` | `orconf_nome` | Preenchido só nas origens customizadas — é o que as faz aparecer na lista |
| `configuracao` | `orconf_configuracao` | `json_array`. Todo o comportamento do canal mora aqui |
| `marketplace` | `mkp_id` | `@ManyToOne` opcional, quando a origem representa um canal de marketplace |

O JSON de `orconf_configuracao` é lido pelo value object `src/CRM/Model/OrigemAtendimentoConfiguracaoModel.php` (`extends AbstractConfiguracaoBase`) — **não é entidade**, não tem tabela. É ele que responde as perguntas de comportamento do canal:

- **Prazo (SLA)** — `getDiasLimiteAtendimento()`, `getHorasLimiteAtendimento()`, `getCondiderarApenasDiasUteis()` (o typo está no fonte)
- **Vínculo com pedido** — `getDiasLimiteVinculoAutomaticoPedido()`
- **Anexos** — `getUtilizaAnexos()`, `getTamanhoMaximoAnexo()`, `getExtensoesAnexos()`, `getTipoPermissaoExtensoesAnexos()` combinado com `TIPO_EXTENSAO_PERMITIDA = 1` / `TIPO_EXTENSAO_NAO_PERMITIDA = 2` — ou seja, a mesma lista serve de allowlist ou denylist conforme o tipo
- **Tamanho da mensagem** — `getQuantidadeMinimaCaracteres()`, `getQuantidadeMaximaCaracteres()`
- **E-mail** — `getRespotaEmailComCopia()` (typo no fonte)
- **Bot/IA** — tratado em [../FluxoConstrutor/ia-atendimento-e-motor-fluxo.md](../FluxoConstrutor/ia-atendimento-e-motor-fluxo.md)

Tela: `OrigemAtendimentoConfiguracaoFormController`.

### Quem atende e de onde os e-mails vêm

**Entidade:** `CRM\Model\OrigemAtendimentoUsuario` → `crm.crm_origem_atendimento_usuario`

Amarra a origem a um **usuário** ou a um **centro de custo** — é o roteamento da fila. Os dois unique constraints são parciais e mutuamente independentes:

```sql
uq_origem_atendimento_usuario      UNIQUE (ate_origem, usu_id)     WHERE usu_id IS NOT NULL
uq_origem_atendimento_centro_custo UNIQUE (ate_origem, cencus_id)  WHERE cencus_id IS NOT NULL
```

**Entidade:** `CRM\Model\OrigemAtendimentoCaixaEntradaEmail` → `crm.crm_origem_atendimento_caixa_entrada_email`

Diz qual endereço de e-mail alimenta qual origem (`caixa_email` é `unique`). É o que faz uma mensagem que chega em `logistica@` virar `ORIGEM_EMAIL_LOGISTICA` e não `ORIGEM_EMAIL`.

### Como se pluga um canal conversacional novo

Esta é a parte que mais se confunde. `AtendimentoOrigemInterface` **não é um contrato de plugin** — é só um contêiner de constantes. O contrato de verdade são três peças:

1. **Um processador de webhook** — estende `src/CRM/Model/Webhook/AbstractWebhookAtendimentoBaseProcessor.php`, que é um Template Method com `processar()` `final`. O canal só implementa quatro métodos:
   - `getPayloadMensagens(array $payload): array` — extrai as mensagens do payload bruto
   - `getIdentificadorRemetente(array $mensagemPayload): string` — quem é o cliente neste canal
   - `getLockPrefix(): string` — o namespace do lock de concorrência
   - `resolverStrategy(array $mensagemPayload): CrmOrigemFormatterStrategyInterface` — qual strategy formata e responde
2. **Uma strategy de formatação e resposta** — implementa `src/CRM/Model/Formatter/Strategy/CrmOrigemFormatterStrategyInterface.php`: `getOrigem()`, os três formatters (atendimento, mensagem, anexo) e o `reply()`, que é o envio de volta ao cliente.
3. **Um `case` em `src/CRM/Model/Formatter/Strategy/CrmOrigemFormatterFactory.php`** — o mapa origem → strategy. Origem sem entrada aqui lança `InvalidArgumentException`.

Mais um controller de webhook na borda (`src/CRM/Controller/Api/`), que só valida e delega ao processador.

O que o template method resolve de graça, e por isso não deve ser reescrito por canal:

- **transação por mensagem**, com rollback e rethrow;
- **lock de concorrência** via `SELECT pg_advisory_xact_lock(hashtext(?))` com chave `prefixo:identificador:loja` — é o que impede que duas entregas simultâneas do mesmo webhook criem dois atendimentos para o mesmo cliente;
- **cache do atendimento dentro do lote** (`$atendimentosLoaded`), para várias mensagens do mesmo remetente caírem no mesmo atendimento;
- **liberação de responsável inativo** — se o dono do atendimento está desativado, o atendimento é solto;
- **notificação assíncrona** da interface ao final de cada mensagem.

Canais conversacionais implementados hoje: WhatsApp, Meta Messenger, Instagram Direct, comentários de Facebook e de Instagram.

> **Fronteira:** o CRM tem a camada de *atendimento* do WhatsApp (webhook, processador, formatters, tela). O **transporte** da API — cliente HTTP e tipos de mensagem — vive em `src/Unico/Controller/WhatsApp/` e `src/Unico/Model/WhatsApp/`. Já o Meta é inteiramente interno ao CRM (`src/CRM/Model/Meta/`, `src/CRM/Controller/Meta/`). Não são simétricos.

---

## Ciclo de vida do atendimento

### Modelo de dados

**Entidade:** `CRM\Model\Atendimento` → `crm.crm_atendimento`
**Repositório:** `CRM\Repository\AtendimentoRepository`

| Propriedade | Coluna | Observação |
|---|---|---|
| `id` | `ate_id` | PK |
| `numeroProtocolo` | `ate_numero_protocolo` | Campo multiuso: protocolo do Magazine Luiza, protocolo de devolução Shopee, identificador de conta no Messenger |
| `dataHora` | `ate_data_hora` | Indexada. **Reescrita no `reabrir()`** — não é a data de criação original |
| `situacao` | `ate_situacao` | Indexada. Ver abaixo |
| `origem` | `ate_origem` | Indexada. `bigint`, não é FK |
| `email` / `emailOrigem` | `ate_email` / `ate_email_origem` | O segundo é a caixa que capturou a mensagem |
| `telefone` | `ate_telefone` | Indexada |
| `modalidade` | `ate_modalidade` | `1 = MODALIDADE_DUVIDA_CLIENTE`, `2 = MODALIDADE_PEDIDO` |
| `motivoCancelamento` | `ate_motivo_cancelamento` | `1` duplicado, `2` inválido, `3` e-mail spam, `4` agradecimento do cliente |
| `cliente` / `responsavel` | `usu_id_cliente` / `usu_id_responsavel` | Quem abriu × quem atende |
| `pedido` | `ped_id` | Vínculo opcional com o pedido |
| `produtoDerivacao` | `proder_id` | Vínculo opcional com o SKU |
| `loja` | `loj_id` | Necessário porque atendimento sem pedido não teria como saber a loja |
| `marketplace` | `mkp_id` | Quando o canal é de marketplace |
| `motivoAtendimento` | `mot_id` | Classificação do assunto (`crm.crm_motivo_atendimento`) |
| `ultimaMensagem` | `ate_ultima_mensagem_id` | Denormalização para a listagem |
| `novaMensagem` | `ate_nova_mensagem` | Flag de "não lido" |
| `revisado` | `ate_revisado` | Alimenta a tela de revisão |
| `dataHoraSessao` | `ate_data_hora_sessao` | Janela de sessão dos bots de resposta |
| `atendimentoPai` | `ate_id_pai` | Autorrelação. Um fluxo pode abrir um atendimento de negociação a partir de outro |
| `panobotParametros` | `ate_panobot_parametros` | `json_array` lido por `AtendimentoPanobotParametros` |
| `identificadorPlataformaExterna` / `dataPlataformaExterna` | `ate_identificador_plataforma_externa` / `ate_data_plataforma_externa` | Chave e payload do canal externo |
| `situacaoMarketplace` | `ate_situacao_marketplace` | Estado da devolução: `1` enviada, `2` aguardando, `3` recebida, `4` finalizada |
| `emailCopia` | `ate_log_email_copia` | Log das cópias enviadas |

### As sete situações

```
SITUACAO_ABERTO               = 1
SITUACAO_EM_ANDAMENTO         = 2
SITUACAO_FINALIZADO           = 3
SITUACAO_IGNORADO             = 4   (Ignorado/Cancelado)
SITUACAO_TRAMITADO            = 5
SITUACAO_LOCK                 = 6
SITUACAO_PENDENTE_VERIFICACAO = 7
```

**Duas delas não são estados de repouso** e por isso nem aparecem em `getSituacaoList()`:

- `SITUACAO_LOCK` é uma **reserva de fila**. `AtendimentoRepository::buscaProximoAtendimento()` faz um `UPDATE ... SET ate_situacao = LOCK WHERE ate_id = ? AND ate_situacao = ABERTO`; se o update afeta zero linhas, outro operador pegou o atendimento primeiro e o método se rechama. É um claim atômico, não um estado de negócio.
- `SITUACAO_TRAMITADO` só existe entre dois flushes dentro de `AtendimentoController::tramite()`. O atendimento é gravado como TRAMITADO **apenas para produzir a linha de log**, e no flush seguinte volta para EM_ANDAMENTO já com o novo responsável. Por isso `getSituacaoLogList()` acrescenta TRAMITADO à lista: ele é visível no histórico, nunca na grid.

### Quem faz cada transição

Todas concentradas em `src/CRM/Controller/AtendimentoController.php`:

| Método | Transição | Quem dispara |
|---|---|---|
| `newAtendimentoSite()` | → `ABERTO` | Formulário do site, captura de e-mail, API |
| `newAtendimentoComentarioSite()` | → `ABERTO` | Comentário de produto |
| `newAtendimentoMensageiroSite()` / `...SitePedido()` | → `ABERTO` | Mensageiro do site |
| `init()` | `ABERTO`/`FINALIZADO`/`IGNORADO`/`LOCK` → `EM_ANDAMENTO` | Operador ao abrir a tela; grava o responsável |
| `tramite()` | `→ TRAMITADO → EM_ANDAMENTO` | Transferência para outro usuário |
| `liberar()` | → `ABERTO`, responsável nulo | Operador devolve o atendimento à fila |
| `finalizar()` | → `FINALIZADO` | Encerramento |
| `pendenteVerificacao()` | → `PENDENTE_VERIFICACAO` | Encerramento com ressalva |
| `reabrir()` | → `ABERTO` | Nova mensagem do cliente em atendimento fechado |

`SITUACAO_IGNORADO` não tem método próprio: é setado direto em `AtendimentoFormController`, sempre acompanhado de um `MOTIVO_CANCELAMENTO_*`.

`finalizar()` e `pendenteVerificacao()` limpam o motivo de cancelamento — encerrar de verdade apaga a marca de descarte.

### A trava que impede regressão de estado

`Atendimento::preUpdate()` tem uma regra que não está escrita em nenhum lugar da interface: **um atendimento EM_ANDAMENTO não pode voltar para ABERTO**. Se o changeset do Doctrine mostra essa transição, o `preUpdate` a desfaz e ainda restaura o responsável anterior.

A única forma de liberar de fato é setar `permiteResponsavelNulo = true` antes — que é exatamente o que `liberar()` faz. Qualquer código que tente devolver um atendimento à fila sem essa flag vai falhar silenciosamente.

### Trilha de auditoria automática

O log **não é escrito pelo chamador**. `Atendimento` tem `@HasLifecycleCallbacks` e monta a linha sozinho:

- `@PrePersist` / `@PreUpdate` → `newLog()` cria um `AtendimentoLog` com situação, usuário e observação correntes, além de `newLogAlteracaoPedido()` e `newLogAlteracaoProdutoDerivacao()`, que registram vínculo/desvínculo de pedido e de SKU;
- `@PostPersist` / `@PostUpdate` → `postPersitUpdate()` persiste o log e tudo o que ficou em `$itemsToPersist`.

A deduplicação é feita por `addHashObservacoes(md5(id . situacao))`, um array estático de request: o mesmo par atendimento+situação só gera uma linha por request, mesmo com vários flushes.

Para carimbar autor e texto no log, o chamador usa os campos transientes `setUsuarioLog()`, `setObservacaoLog()` e `setDataArrayFormularioLog()` **antes** do flush. Preencher `dataArrayFormularioLog` muda o tipo do log para `TIPO_FORMULARIO`.

**Entidade:** `CRM\Model\AtendimentoLog` → `crm.crm_atendimento_log` — `atelog_situacao`, `atelog_observacao`, `atelog_data_array` (json), `atelog_tipo` (`1` padrão, `2` formulário, `3` alteração de pedido).

### A mensagem e o prazo

**Entidade:** `CRM\Model\AtendimentoMensagem` → `crm.crm_atendimento_mensagem`

| Propriedade | Coluna | Observação |
|---|---|---|
| `dataHora` | `atemsg_data_hora` | |
| `dataHoraLimite` | `atemsg_data_hora_limite` | **O SLA.** Calculado no próprio model a partir da configuração da origem |
| `dataHoraVisualizada` | `atemsg_data_hora_visualizada` | |
| `observacao` | `atemsg_observacao` | O texto da mensagem. Nullable |
| `situacao` | `atemsg_situacao` | `1 = NOVA`, `2 = LIDA`, `3 = REMOVIDA` |
| `tipo` | `atemsg_tipo` | `1 = TIPO_PADRAO`, `2 = TIPO_EVENTO` |
| `respondido` | `atemsg_respondido` | |
| `revisado` | `atemsg_revisado` | Entra no indicador de revisões |
| `manual` | `atemsg_manual` | Distingue mensagem digitada de mensagem gerada |
| `params` | `atemsg_params` | `json_array` |
| `processoTramite` | `protra_id` | Vínculo com o trâmite que originou a mensagem |
| `mensagemPai` | `atemsg_id_mensagem_pai` | Encadeamento de respostas |
| `identificadorPlataformaExterna` | `atemsg_identificador_plataforma_externa` | Id da mensagem no canal externo |

O prazo é calculado no persist da mensagem, somando `getDiasLimiteAtendimento()` e `getHorasLimiteAtendimento()` da origem. Com `getCondiderarApenasDiasUteis()` ligado, os dias são somados como dias úteis e, se o resultado das horas cair em dia não útil, o vencimento é empurrado para o próximo dia útil. Esse campo é o denominador de toda a tela de performance.

`@PostPersist` da mensagem dispara `AtendimentoBotFilaEventController` — é aqui que a mensagem do cliente entra na fila do bot.

### Anexos, produtos e assistência

- `AtendimentoMensagemAnexo` → `crm.crm_atendimento_mensagem_anexo` — o arquivo em si vai para `DocumentoEletronico`; a validação de extensão e de tamanho vem da configuração da origem.
- `AtendimentoProduto` → `crm.crm_atendimento_produto` — quais SKUs a conversa trata (`ate_id` + `proder_id`).
- `AtendimentoProdutoAssistencia` → `crm.crm_atendimento_produto_assistencia` — desdobramento por peça: aponta para o `AtendimentoProduto` e para outro `proder_id`, com quantidade. É o que permite pedir três peças de reposição diferentes dentro do mesmo produto.
- `MotivoAtendimento` → `crm.crm_motivo_atendimento` — `mot_descricao` + `mot_ativo`.

### Telas

| Controller | Papel |
|---|---|
| `Grid/AtendimentoGridController` | Lista de trabalho. As ações de ignorar/finalizar são condicionadas por situação |
| `Form/AbrirAtendimentoFormController` | Abertura manual pelo operador |
| `Form/AtendimentoFormController` | A tela de atendimento: mensagens, anexos, vínculos, trâmite, encerramento |
| `Form/RespostaClienteFormController` | Resposta direta ao cliente |
| `Form/WhatsAppFormController` | Interface de chat do WhatsApp, com os modelos de mensagem do canal |
| `Grid/AtendimentoLogGridController` | Histórico |

---

## Contenções: bloqueio, spam e resposta automática

São três mecanismos com nomes parecidos e propósitos completamente diferentes. Confundi-los é a armadilha mais comum do módulo.

### `SpamAtendimento` — impedir que lixo vire atendimento

**Entidade:** `CRM\Model\SpamAtendimento` → `crm.crm_spam_atendimento` (`spam_email`, `spam_termo`, `spam_origem`)

Cada linha cadastra **ou** um e-mail **ou** um termo, opcionalmente restrito a uma origem. `AtendimentoController::validarSpam()` roda antes da abertura: converte o HTML da mensagem em texto puro (`StringUtil::htmlToText`) e consulta `isEmailSpam()` e `isTermoSpam()`. Batendo qualquer um dos dois, o atendimento simplesmente não é criado.

**Problema que resolve:** caixas de e-mail públicas recebem marketing e automação de terceiros; sem esse filtro, cada um vira uma unidade de trabalho para alguém fechar à mão.

### `AtendimentoRespostaAutomaticaBloqueio` — cortar o loop de e-mail

**Entidade:** `CRM\Model\AtendimentoRespostaAutomaticaBloqueio` → `crm.crm_atendimento_resposta_automatica_bloqueio` (só `resblq_email`)

É uma **denylist do e-mail de confirmação de abertura**. `AtendimentoController::sendNewAtendimentoEmail()` carrega a lista e, se o e-mail do atendimento estiver nela, não envia a notificação.

**Problema que resolve:** quando o remetente é ele próprio um sistema automático (um no-reply, um robô de transportadora, outro help desk), a confirmação de abertura gera uma nova mensagem, que abre outro atendimento, que gera outra confirmação. O loop só para cadastrando o endereço aqui.

O mesmo método tem uma segunda guarda: atendimento sem e-mail de contato **não** envia nada, para evitar o fallback silencioso para a caixa da loja.

### `UsuarioBloqueioMensagem` — controlar o operador, não o cliente

**Entidade:** `CRM\Model\UsuarioBloqueioMensagem` → `crm.crm_usuario_bloqueio_mensagem` (`usu_id`, `msg_livres_bloqueio_msg`)

Apesar do nome, **não tem nada a ver com bloquear cliente**. É a regra de quem, dentro da empresa, pode escrever texto livre para o cliente: `AtendimentoFormController::defineBloqueioMensagemUsuario()` lê a linha do usuário logado e decide entre liberar o campo de mensagem ou travá-lo para uso exclusivo de textos padrão.

**Problema que resolve:** operador novo ou terceirizado respondendo o que quer em nome da marca. A trava obriga o uso do catálogo aprovado.

### `replyBlocked` — silenciar o bot em um atendimento

Não é entidade: é uma flag dentro de `ate_panobot_parametros`, manipulada por `src/CRM/Model/AtendimentoPanobotParametros.php` (`setReplyBlocked()` / `getReplyBlocked()`). É o que os nodes `BloqueiaAtendimentoNode` e `DesbloqueiaAtendimentoNode` acionam.

**Problema que resolve:** quando a conversa passa para um humano, o bot precisa calar a boca sem que se desligue a IA da origem inteira. O bloqueio é por atendimento, reversível, e é logado.

---

## Textos padrão e FAQ

### `TextoPadrao` é duas coisas na mesma tabela

**Entidade:** `CRM\Model\TextoPadrao` → `crm.crm_texto_padrao`

| Propriedade | Coluna | Observação |
|---|---|---|
| `tipo` | `tex_tipo` | Ver abaixo |
| `titulo` | `tex_titulo` | |
| `texto` | `tex_texto` | **Template Twig** |
| `descricao` | `tex_descricao` | Usada pela IA para escolher o texto |
| `configuracao` | `tex_configuracao` | `json_array`, lido por `TextoPadraoConfiguracao` |
| `usuario` | `usu_id` | Preenchido no tipo Usuário |
| `centroCusto` | `cencus_id` | Preenchido no tipo Centro de Custo |

Os cinco tipos partem em dois grupos:

- **Uso humano** — o operador escolhe na tela
  - `TIPO_GLOBAL = 1`
  - `TIPO_USUARIO = 2`
  - `TIPO_CENTRO_CUSTO = 3`
- **Uso da IA** — são as respostas que o bot pode dar
  - `TIPO_PANOBOT_IA_ATENDIMENTO = 4`
  - `TIPO_PANOBOT_IA_PROCESSO_TRAMITE = 5`

Por isso `getTipoList()` recebe `$ignorePanoBotIA` e `$ignoreUseSelect`: a mesma tabela alimenta dois seletores que nunca devem se misturar. A grid `TextoPadraoPanoBotGridController` é só um `BaseListController` filtrando `tex_tipo IN (4, 5)`.

### Sugestão contextual

`AtendimentoController::loadListTextoSituacao()` não devolve o catálogo inteiro: filtra pelas condições gravadas em `tex_configuracao.opcoes` —

- `situacoesPedido` — só sugere o texto se o pedido do atendimento estiver numa dessas situações
- `emAtraso` — restringe ainda mais, só quando o pedido está atrasado
- `canalVenda` — restringe por marketplace/loja do pedido
- `pedidoNaoEncontrado` — textos para atendimento sem pedido vinculado

Quando as duas condições (situação e canal) estão configuradas, ambas precisam bater. Se o filtro não sobra nada, o método devolve a lista completa em vez de uma tela vazia.

### Resolução do template

O render acontece em `src/CRM/Controller/Api/Panobot/Core/Responses/ProcessTextoPadraoResponse.php` e tem uma característica que vale copiar:

1. `getRequiredVariables()` varre o texto procurando **quais variáveis do catálogo aparecem ali**;
2. só essas são carregadas do ERP (`addDataTemplatesRequireds`);
3. o texto é renderizado como template **Twig**;
4. o que sobrar de `{variavel}` simples é resolvido por `str_replace` como rede de segurança.

Ou seja: o custo de montar uma resposta é proporcional ao que o texto realmente cita, não ao tamanho do catálogo de variáveis.

`TextoPadraoConfiguracao` (value object, sem tabela) ainda define:

- `TIPO_RESPOSTA_MENSAGEM = 1` × `TIPO_RESPOSTA_PARAMETROS = 2` — se o texto vira mensagem para o cliente ou um JSON de parâmetros consumido por quem chamou;
- `TIPO_RENDER_RESPOSTA_PARAMETRO_TEXTO = 1` × `TIPO_RENDER_RESPOSTA_PARAMETRO_JSON = 2` — como cada parâmetro é serializado;
- `getRealizaAberturaProcesso()`, `getFluxo()`, `getFluxoAtualSituacao()`, `getPrioridade()` — um texto padrão pode **abrir um processo** ao ser usado (ver [../Unico/processos-centro-custo.md](../Unico/processos-centro-custo.md));
- `getReplyBlocked()` — o texto pode silenciar o bot ao ser respondido.

### Mensagens do WhatsApp

`MensagemPadraoWhatsApp` também **não é entidade** — é um DTO `JsonSerializable` usado por `WhatsAppFormController` para montar a lista de modelos da tela, com `TIPO_TEXTO_PADRAO = 1` (texto do catálogo) e `TIPO_TEMPLATE = 2` (template aprovado na Meta, único formato aceito fora da janela de 24h).

### FAQ

- `FaqGrupo` → `crm.crm_faq_grupo` — `faqgru_nome`, `loj_id` obrigatório e autorrelação `faqgru_id_pai` (árvore de categorias por loja)
- `FaqDuvida` → `crm.crm_faq_duvida` — `faqduv_pergunta`, `faqduv_resposta`, `faqgru_id` obrigatório

O FAQ é conteúdo público da loja, não catálogo de resposta do operador. São coisas separadas de propósito.

---

## Solicitação de troca e reembolso

Fluxo de pós-venda do cliente, ancorado no pedido.

**Entidade:** `CRM\Model\SolicitacaoTrocaReembolso` → `crm.crm_solicitacao_troca_reembolso`

| Propriedade | Coluna | Observação |
|---|---|---|
| `status` | `str_status` | Ver abaixo |
| `pedido` | `ped_id` | Obrigatório — a solicitação sempre nasce de um pedido |
| `currentStep` | `str_current_step` | Passo do wizard no site |
| `cupom` | `cupom_id` | Quando o reembolso vira voucher |
| `opcaoColeta` | `str_opc_coleta` | `1 = POSTAGEM_CORREIOS`, `2 = TRANSPORTADORA` |
| `formaReembolso` | `str_forma_reemb` | `1 = PIX`, `2 = TRANSFERENCIA_BANCARIA` |

Os oito status:

```
1 INICIADA · 2 EM_ANALISE · 3 APROVADA · 4 EM_ANDAMENTO
5 ITENS_RECEBIDOS · 6 ITENS_RECEBIDOS_PARCIALMENTE · 7 NEGADA · 8 FINALIZADA
```

Tabelas satélites:

- `SolicitacaoTrocaReembolsoHistorico` → `crm.crm_solicitacao_troca_reembolso_historico` — uma linha por mudança de status, com usuário e data. Repete as mesmas oito constantes, de propósito: o histórico não deve mudar de significado quando o enum principal mudar.
- `SolicitacaoTrocaReembolsoPedidoItem` → `crm.crm_solicitacao_troca_reembolso_pedido_item` — quais itens do pedido entram, **com o tipo por item** (`TIPO_SOLICITACAO_*`): `1 = TROCA`, `2 = REEMBOLSO`, `3 = REEMBOLSO_POR_VOUCHER`, `4 = ASSISTENCIA`. Ou seja, uma mesma solicitação pode pedir troca de um item e reembolso de outro. `strpi_recebido` marca o retorno físico, e é o que separa `ITENS_RECEBIDOS` de `ITENS_RECEBIDOS_PARCIALMENTE`.
- `SolicitacaoTrocaReembolsoProdutoDerivacao` → `crm.crm_solicitacao_troca_reembolso_produto_derivacao` — os SKUs escolhidos para a troca.

> Existe apenas `Grid/SolicitacaoTrocaReembolsoGridController`. **Não há form controller** no admin: a solicitação é criada pelo cliente no site e o admin acompanha e decide.

---

## Indicadores de performance

Cinco grids, todos servidos por `AtendimentoRepository`:

- `AtendimentoPerformanceGridController` — por usuário
- `AtendimentoPerformanceOrigemGridController` — por usuário × origem
- `AtendimentoPerformanceDetalhesGridController` e `AtendimentoPerformanceDetalhesOrigemGridController` — o detalhamento atendimento a atendimento
- `RevisaoAtendimentosGridController` — fila de revisão de mensagens

### Como os números saem

Tudo é montado sobre uma **tabela temporária** `atendimentos`, criada por `createTemporaryTableAtendimentos()` com os filtros da tela e destruída no `__destruct()` do grid controller (`dropTemporaryTableAtendimentos()`). Ela é um join já filtrado de `crm_atendimento` com `crm_atendimento_log`, e é sobre ela que as métricas rodam.

Duas medidas de tempo, e elas medem coisas opostas:

- **`performance_atendimento` — o atraso.** Para cada mensagem com `atemsg_data_hora_limite` preenchido, soma `atelog_data_hora (FINALIZADO) − atemsg_data_hora_limite`. Só conta a finalização que **não tem outro EM_ANDAMENTO depois dela**, isto é, o encerramento definitivo. Valor negativo significa resposta dentro do prazo.
- **`custo_atendimento` — o tempo de trabalho.** Para cada log de EM_ANDAMENTO, soma até a primeira finalização **do mesmo usuário**. É o esforço gasto, independente de prazo.

Indicadores de apoio por usuário: `finalizados`, `cancelados`, `pedidos` tocados, `pedidos_cancelados` (cancelados depois do atendimento), `media_mensagens` por atendimento e `revisoes`.

> **Exceção codificada:** quando `BaseController::getLojaBaseID() == Loja::LOJA_PANORAMA`, as duas colunas de tempo retornam `'00:00:00'` fixo. A tela funciona, mas os tempos não significam nada nessa base. Isso está no SQL, não em configuração.

---

## Fronteiras do módulo

O que **não** está no CRM, apesar de parecer:

| Assunto | Onde vive | Documento |
|---|---|---|
| Motor de IA, Panobot, Fluxo Construtor, RAG | `src/CRM/Controller/Api/Panobot/` e `src/Unico/Model/FluxoConstrutor/` | [../FluxoConstrutor/ia-atendimento-e-motor-fluxo.md](../FluxoConstrutor/ia-atendimento-e-motor-fluxo.md) |
| Processo, trâmite, centro de custo | `src/Unico/` (schema `processo.*`) | [../Unico/processos-centro-custo.md](../Unico/processos-centro-custo.md) |
| Transporte da API WhatsApp | `src/Unico/Controller/WhatsApp/`, `src/Unico/Model/WhatsApp/` | — |
| Pedido, situação, rastreio | `src/Vendas/` | [../Vendas/prazo-previsao-entrega.md](../Vendas/prazo-previsao-entrega.md) |
| Pedidos e pós-venda de marketplace | `src/Hub/` | [../Marketplaces/fluxo-pedido.md](../Marketplaces/fluxo-pedido.md) |

A ponte formal entre atendimento e processo é a entidade `ProcessoAtendimentoOrigem` (`processo.pro_processo_atendimento_origem`), documentada no lado do motor de processos.
