# 09 · Documentos e aprovação

Fonte: https://muriloht.com/idempiere/09-documentos-e-aprovacao (Guia iDempiere, Murilo H. Torquato). Versão alvo: iDempiere 14.

Objetivos:
- Entender o que faz de um registro um documento no iDempiere
- Transformar a visita em documento, com número, status e o botão de ação
- Implementar o ciclo de vida na classe M: preparar, completar, anular, reativar
- Controlar quais ações aparecem para cada status e perfil
- Exigir aprovação para visitas urgentes
- Migrar os dados existentes quando o modelo muda

## Antes de começar

- Módulo 08 concluído. A `MVisita` tem a regra de conclusão lendo a mensagem `EDU_VisitaSemLinhas` e a configuração `EDU_VISITA_EXIGE_LINHA`.
- Confira que o usuário **GardenUser** entra no sistema. Ele é o técnico sem poder de aprovação neste módulo.
- É o módulo mais longo do guia. Vale fazer em duas sessões: partes 1 e 2 numa, parte 3 na outra.

## O que você vai construir

Até aqui, "concluir" uma visita era marcar uma caixa. Isso não basta para um processo de verdade: não há número para citar ao cliente, não há como anular uma visita sem apagá-la, e qualquer um conclui qualquer coisa.

Neste módulo, a visita vira um **documento**, como um pedido ou uma fatura:

- **Número automático** (`DocumentNo`), com prefixo configurável.
- **Status** (`DocStatus`): Drafted, In Progress, Completed, Voided...
- **Botão de ação** (`DocAction`): o usuário escolhe Completar, Anular, Reativar, e o iDempiere valida cada passo.
- **Aprovação**: visita urgente só completa depois que alguém com permissão aprova.

## O que faz um documento

| Peça | Onde fica | O que faz |
|---|---|---|
| Colunas `DocumentNo`, `DocStatus`, `DocAction`, `Processed`, `Processing` | Tabela | Guardam número, status e a próxima ação |
| Workflow do tipo **Document Process** | Dicionário | É o que o botão `DocAction` executa |
| Interface `DocAction` | Classe M | Um método por ação: `prepareIt`, `completeIt`, `voidIt`... |
| `DocumentEngine` | Core | Decide quais ações valem em cada status e chama o método certo |
| Interface `DocOptions` (opcional) | Classe M | Muda as ações oferecidas ao usuário |

O ciclo que o usuário vê:

```
Drafted ──Prepare──▶ In Progress ──Complete──▶ Completed ──Close──▶ Closed
   │                    │   ▲                     │
   │                    │   └──── Re-activate ────┤
   └────── Void ────────┴─────────────────────────┴──▶ Voided
```

Quando um documento fica **Processed**, o iDempiere o trata como somente leitura na tela. É isso que protege um documento completo de ser alterado por engano.

## Parte 1: as colunas e o workflow

### 1. Complete a tabela

O processo **Create/Complete Table** do módulo 01 também completa uma tabela que já existe, e cria o workflow do documento de brinde.

Como **System**, abra `EDU_Visita` em **Table and Column** e rode **Create/Complete Table** pelo botão de processos da aba **Table** (rodando a partir do registro, ele completa a tabela em vez de criar outra). Marque:

| Parâmetro | Valor |
|---|---|
| Create 'DocumentNo' column | marcado |
| Create 'IsApproved' column | marcado |
| Create a Workflow | marcado |

**Create a Workflow** faz três coisas:

- cria as colunas `DocAction`, `DocStatus`, `Processed`, `ProcessedOn` e `Processing`;
- cria o workflow `Process_EDU_Visita`, com os nós padrão de documento (Start, DocPrepare, DocComplete, DocAuto);
- cria o processo `EDU_Visita Process`, liga ao workflow e coloca esse processo no botão da coluna `DocAction`.

As colunas são criadas só no Dicionário. Abra cada uma na aba **Column** e clique em **Synchronize Column**: `DocumentNo`, `DocAction`, `DocStatus`, `Processed`, `ProcessedOn`, `Processing` e `IsApproved`. As obrigatórias já vêm com valor padrão (`DocStatus` = `DR`, `DocAction` = `CO`, os Yes-No = `N`), então o `ALTER TABLE` funciona mesmo com visitas no banco.

Em `DocumentNo`, marque também **Identifier** com Sequence `0`: o número passa a identificar a visita nas listas.

### 2. A janela

Na aba **Visita** da janela **Window, Tab and Field**, rode **Create Fields** e organize no **Tab Editor**: `DocumentNo` no topo; `DocStatus` e o botão `DocAction` no rodapé, como nos pedidos do core.

Três ajustes na aba **Field**:

| Campo | Ajuste |
|---|---|
| `Concluída` | **Read Only** marcado. Agora quem conclui é o documento |
| `Approved` | **Read Only** marcado. Quem aprova é a ação Approve |
| `Processed`, `ProcessedOn`, `Processing` | **Displayed** desmarcado |

E na aba **Tab**, na aba **Linhas**, troque a **Read Only Logic** do módulo 02:

| Campo | Valor |
|---|---|
| Read Only Logic | `@Processed@=Y` |

A aba Linhas não tem a coluna `Processed`, então o `@Processed@` é lido do cabeçalho, como no módulo 02.

### 3. Numeração

Nada a configurar: na primeira visita salva, o iDempiere cria sozinho a sequência `DocumentNo_EDU_Visita` para a GardenWorld e numera a partir dela.

Para ter números como `VT-1000001`, abra a janela **Document Sequence**, localize `DocumentNo_EDU_Visita` (depois de salvar a primeira visita) e preencha **Prefix** com `VT-`.

## Parte 2: a classe M vira documento

### 4. A nova MVisita

Regenere as classes com o **Generate Model** (módulo 04): a `X_EDU_Visita` ganha `getDocumentNo()`, `getDocStatus()`, `setDocAction()`, `setProcessed()`, `isApproved()` e os demais.

Crie também duas mensagens na janela **Message** (Entity Type `EDU`):

| Search Key | Message Type | Message Text |
|---|---|---|
| `EDU_AguardandoAprovacao` | Information | `Visita urgente: aguardando aprovação.` |
| `EDU_SemPermissaoAprovar` | Error | `Seu perfil não pode aprovar visitas.` |

Agora substitua a `MVisita` inteira por esta versão. Leia com calma: cada método é um passo do ciclo.

```java
package com.gardenworld.visitas.model;

import java.io.File;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.util.List;
import java.util.Properties;

import org.compiere.model.MBPartnerLocation;
import org.compiere.model.MRole;
import org.compiere.model.MSysConfig;
import org.compiere.model.ModelValidationEngine;
import org.compiere.model.ModelValidator;
import org.compiere.model.Query;
import org.compiere.process.DocAction;
import org.compiere.process.DocOptions;
import org.compiere.process.DocumentEngine;
import org.compiere.util.Msg;

public class MVisita extends X_EDU_Visita implements DocAction, DocOptions {

    private static final long serialVersionUID = 1L;

    public static final String SYSCONFIG_EXIGE_LINHA = "EDU_VISITA_EXIGE_LINHA";

    /** Mensagem do último processamento, mostrada ao usuário em caso de erro. */
    private String m_processMsg = null;
    /** O prepareIt acabou de rodar nesta mesma ação? */
    private boolean m_justPrepared = false;

    public MVisita(Properties ctx, int EDU_Visita_ID, String trxName) {
        super(ctx, EDU_Visita_ID, trxName);
    }

    public MVisita(Properties ctx, String EDU_Visita_UU, String trxName) {
        super(ctx, EDU_Visita_UU, trxName);
    }

    public MVisita(Properties ctx, ResultSet rs, String trxName) {
        super(ctx, rs, trxName);
    }

    /** Linhas da visita, em ordem. */
    public List<X_EDU_VisitaLinha> getLinhas() {
        return new Query(getCtx(), X_EDU_VisitaLinha.Table_Name,
                X_EDU_VisitaLinha.COLUMNNAME_EDU_Visita_ID + "=?", get_TrxName())
            .setParameters(getEDU_Visita_ID())
            .setOrderBy(X_EDU_VisitaLinha.COLUMNNAME_Line)
            .list();
    }

    /** Endereço padrão do cliente: o primeiro de entrega; se não houver, o primeiro ativo. */
    public static int getEnderecoPadrao(Properties ctx, int C_BPartner_ID, String trxName) {
        return new Query(ctx, MBPartnerLocation.Table_Name,
                "C_BPartner_ID=? AND IsActive='Y'", trxName)
            .setParameters(C_BPartner_ID)
            .setOrderBy("IsShipTo DESC, C_BPartner_Location_ID")
            .firstId();
    }

    @Override
    protected boolean beforeSave(boolean newRecord) {
        if (getC_BPartner_Location_ID() <= 0
                || is_ValueChanged(COLUMNNAME_C_BPartner_ID) && !is_ValueChanged(COLUMNNAME_C_BPartner_Location_ID)) {
            setC_BPartner_Location_ID(getEnderecoPadrao(getCtx(), getC_BPartner_ID(), get_TrxName()));
        }
        return true;
    }

    // ------------------------------------------------------------------
    // Documento
    // ------------------------------------------------------------------

    @Override
    public boolean processIt(String action) throws Exception {
        m_processMsg = null;
        DocumentEngine engine = new DocumentEngine(this, getDocStatus());
        return engine.processIt(action, getDocAction());
    }

    @Override
    public boolean unlockIt() {
        setProcessing(false);
        return true;
    }

    @Override
    public boolean invalidateIt() {
        return true;
    }

    @Override
    public String prepareIt() {
        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_BEFORE_PREPARE);
        if (m_processMsg != null)
            return DocAction.STATUS_Invalid;

        if (MSysConfig.getBooleanValue(SYSCONFIG_EXIGE_LINHA, true, getAD_Client_ID())
                && getLinhas().isEmpty()) {
            m_processMsg = Msg.getMsg(getCtx(), "EDU_VisitaSemLinhas");
            return DocAction.STATUS_Invalid;
        }

        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_AFTER_PREPARE);
        if (m_processMsg != null)
            return DocAction.STATUS_Invalid;

        m_justPrepared = true;
        return DocAction.STATUS_InProgress;
    }

    @Override
    public boolean approveIt() {
        if (!MRole.getDefault().isCanApproveOwnDoc()) {
            m_processMsg = Msg.getMsg(getCtx(), "EDU_SemPermissaoAprovar");
            return false;
        }
        setIsApproved(true);
        return true;
    }

    @Override
    public boolean rejectIt() {
        setIsApproved(false);
        return true;
    }

    @Override
    public String completeIt() {
        if (!m_justPrepared) {
            String status = prepareIt();
            m_justPrepared = false;
            if (!DocAction.STATUS_InProgress.equals(status))
                return status;
        }

        // Visita urgente só completa depois de aprovada
        if (isEDU_IsUrgente() && !isApproved()) {
            m_processMsg = Msg.getMsg(getCtx(), "EDU_AguardandoAprovacao");
            return DocAction.STATUS_InProgress;
        }

        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_BEFORE_COMPLETE);
        if (m_processMsg != null)
            return DocAction.STATUS_Invalid;

        String valid = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_AFTER_COMPLETE);
        if (valid != null) {
            m_processMsg = valid;
            return DocAction.STATUS_Invalid;
        }

        setEDU_IsConcluida(true);
        setProcessed(true);
        setDocAction(DocAction.ACTION_Close);
        return DocAction.STATUS_Completed;
    }

    @Override
    public boolean voidIt() {
        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_BEFORE_VOID);
        if (m_processMsg != null)
            return false;

        setEDU_IsConcluida(false);
        setProcessed(true);
        setDocAction(DocAction.ACTION_None);

        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_AFTER_VOID);
        return m_processMsg == null;
    }

    @Override
    public boolean closeIt() {
        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_BEFORE_CLOSE);
        if (m_processMsg != null)
            return false;

        setProcessed(true);
        setDocAction(DocAction.ACTION_None);

        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_AFTER_CLOSE);
        return m_processMsg == null;
    }

    @Override
    public boolean reverseCorrectIt() {
        return false; // não se aplica a visitas
    }

    @Override
    public boolean reverseAccrualIt() {
        return false; // não se aplica a visitas
    }

    @Override
    public boolean reActivateIt() {
        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_BEFORE_REACTIVATE);
        if (m_processMsg != null)
            return false;

        setEDU_IsConcluida(false);
        setProcessed(false);
        setDocAction(DocAction.ACTION_Complete);

        m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_AFTER_REACTIVATE);
        return m_processMsg == null;
    }

    @Override
    public int customizeValidActions(String docStatus, Object processing, String orderType, String isSOTrx,
            int AD_Table_ID, String[] docAction, String[] options, int index) {
        // Urgente em processo: quem pode aprovar vê Aprovar e Rejeitar
        if (DocAction.STATUS_InProgress.equals(docStatus) && isEDU_IsUrgente() && !isApproved()
                && MRole.getDefault().isCanApproveOwnDoc()) {
            options[index++] = DocAction.ACTION_Approve;
            options[index++] = DocAction.ACTION_Reject;
        }
        // Completa: além de Fechar, permite Anular e Reativar
        if (DocAction.STATUS_Completed.equals(docStatus)) {
            options[index++] = DocAction.ACTION_Void;
            options[index++] = DocAction.ACTION_ReActivate;
        }
        return index;
    }

    @Override
    public String getSummary() {
        return getDocumentNo() + " - " + getDescription();
    }

    @Override
    public String getDocumentInfo() {
        return Msg.getElement(getCtx(), COLUMNNAME_EDU_Visita_ID) + " " + getDocumentNo();
    }

    @Override
    public File createPDF() {
        return null;
    }

    @Override
    public String getProcessMsg() {
        return m_processMsg;
    }

    @Override
    public int getDoc_User_ID() {
        return getSalesRep_ID();
    }

    @Override
    public int getC_Currency_ID() {
        return 0;
    }

    @Override
    public BigDecimal getApprovalAmt() {
        return BigDecimal.ZERO;
    }
}
```

O que mudou em relação ao módulo 08, e por quê:

- **A regra "precisa de linha" saiu do `beforeSave` e foi para o `prepareIt`.** Antes, a regra disparava ao marcar a caixa. Agora, é validação do documento: roda quando alguém tenta preparar ou completar. O `beforeSave` ficou só com o endereço padrão.
- **`prepareIt` valida, `completeIt` efetiva.** O `prepareIt` não muda nada no registro; se algo estiver errado, devolve `STATUS_Invalid` com a mensagem em `m_processMsg`, e o usuário vê essa mensagem. O `completeIt` é quem marca `Processed`, `EDU_IsConcluida` e a próxima ação.
- **`fireDocValidate` antes e depois de cada passo.** É isso que dispara os event handlers de documento de outros plugins (`@BeforeComplete`, `@AfterComplete`...). Sem essas linhas, o seu documento fica invisível para eles.
- **O `DocumentEngine` decide o status.** Os métodos não chamam `setDocStatus`: devolvem o status ou `true`/`false`, e o engine grava o resto.
- **`customizeValidActions` (interface `DocOptions`)** acrescenta ações às que o engine oferece por padrão: Void e Re-activate numa visita completa; Approve e Reject numa urgente em processo, só para quem pode aprovar.
- **`approveIt` confere a permissão de novo.** Esconder o botão não é segurança: a ação pode chegar por um processo ou pela API. A regra de verdade fica no servidor.
- **`reverseCorrectIt` e `reverseAccrualIt` devolvem `false`.** Estorno faz sentido para documentos contábeis. Para uma visita, anular basta.

### 5. "Em aberto" agora é `Processed='N'`

Três lugares consultam visitas em aberto com `EDU_IsConcluida='N'`. Com documentos, uma visita **anulada** também tem `EDU_IsConcluida='N'`, mas não está em aberto. O critério certo passa a ser `Processed='N'`: rascunho, em processo ou aprovada.

| Onde | Troque por |
|---|---|
| `ClienteComVisitaAberta` (módulo 05) | `"C_BPartner_ID=? AND Processed='N'"` |
| `GerarVisitasManutencao` (módulo 06) | `v.Processed='N'` no `NOT EXISTS` |
| Status Line (módulo 08) | `... AND Processed='N' AND IsActive='Y'` |

Toda mudança de modelo pede essa pergunta: **quem mais lê o dado que mudou de significado?**

### 6. Migre as visitas que já existem

As visitas marcadas como concluídas antes deste módulo ficaram como `Drafted`. Elas precisam de um status coerente:

```sql
UPDATE EDU_Visita
   SET DocStatus='CO', DocAction='CL', Processed='Y'
 WHERE EDU_IsConcluida='Y' AND DocStatus='DR';

UPDATE EDU_Visita
   SET DocumentNo=EDU_Visita_ID::text
 WHERE DocumentNo IS NULL;
```

No seu banco, rode no `psql`. Nos outros ambientes, o mesmo SQL vai no 2Pack (passo 9), para rodar uma única vez na instalação da versão.

> Migração de dados é parte da entrega, não uma limpeza posterior. Um plugin que muda o modelo e deixa os dados antigos incoerentes está quebrado, mesmo que o código esteja certo.

## Parte 3: testando o ciclo e a aprovação

### 7. O ciclo normal

Reinicie o servidor. Como **GardenAdmin**:

1. Crie uma visita não urgente, sem linhas, e salve. Ela recebe um número e fica **Drafted**.
2. Clique em **Document Action**, escolha **Complete**. O iDempiere recusa com a mensagem `EDU_VisitaSemLinhas`, e a visita não completa.
3. Inclua uma linha e complete. O status vira **Completed**, `Concluída` fica marcada e nada mais pode ser editado, nem nas linhas.
4. **Document Action** agora oferece **Close**, **Void** e **Re-activate**. Reative: a visita volta a **In Progress** e fica editável.
5. Complete de novo e, em seguida, anule (**Void**). Status **Voided**, somente leitura para sempre.

### 8. A aprovação

Rode **Role Access Update** para garantir que o perfil **GardenWorld User** acesse a janela e o workflow.

1. Entre como **GardenUser**. Crie uma visita **urgente**, com motivo e uma linha, e complete. A visita para em **In Progress**: o `completeIt` não completa sem aprovação. Abra o **Document Action** de novo: não há opção de aprovar para esse perfil.
2. Entre como **GardenAdmin** (perfil **GardenWorld Admin**, que tem **Approve own Documents**). Abra a mesma visita. O **Document Action** agora oferece **Approve** e **Reject**.
3. Aprove. O status vira **Approved** e a caixa **Approved** fica marcada.
4. Complete. Agora vai.

Coloque um breakpoint no `customizeValidActions` e no `completeIt` e repita, para ver a ordem das chamadas.

### 9. Commit e pacote

No Pack Out, inclua a tabela `EDU_Visita` (colunas novas), o workflow `Process_EDU_Visita` (tipo **Workflow**; ele leva os nós e as transições), o processo `EDU_Visita Process`, a janela `Visita Técnica` e as mensagens novas. Por último, a migração de dados:

| Type | SQL Expression/Statement |
|---|---|
| SQL Statement | os dois `UPDATE` do passo 6 |

Gere o `2Pack_1.0.5.zip`.

```bash
git add .
git commit -m "Visitas 1.0.5: visita como documento, com aprovação de urgentes"
```

## Checkpoint

- [ ] A visita tem número automático, status e o botão Document Action
- [ ] Completar uma visita sem linhas mostra a mensagem de erro e não completa
- [ ] Uma visita completa fica somente leitura, inclusive as linhas
- [ ] Uma visita completa pode ser reativada e anulada
- [ ] Como GardenUser, uma visita urgente fica In Progress ao tentar completar
- [ ] Como GardenAdmin, aprovo a visita urgente e depois completo
- [ ] As visitas concluídas antes deste módulo aparecem como Completed

## Erros comuns

| Sintoma | Causa provável | Correção |
|---|---|---|
| Create/Complete Table diz que a tabela já existe | Rodou pelo menu, não a partir do registro da tabela | Abra `EDU_Visita` e rode pelo botão de processos da aba Table |
| **Synchronize Column** falha em `DocStatus` | Coluna obrigatória sem valor padrão | Confira Default Logic `DR` na coluna |
| O botão **Document Action** não faz nada | Coluna `DocAction` sem processo, ou workflow sem acesso | Confira o **Process** da coluna; rode **Role Access Update** |
| "Persistent Object not DocAction" | A factory devolveu outra classe, ou a `MVisita` não implementa `DocAction` | Confira a classe e reinicie |
| Visita completa continua editável | Faltou `setProcessed(true)` no `completeIt` | Passo 4 |
| Linhas editáveis numa visita completa | Read Only Logic da aba Linhas ainda com `@EDU_IsConcluida@` | Passo 2 |
| GardenAdmin não vê **Approve** | Visita não urgente, já aprovada, ou fora de **In Progress** | Confira as condições do `customizeValidActions` |
| Números saem sem o prefixo | Prefixo preenchido depois dos números já gerados | Só vale para os próximos números |

## Desafio

1. Mostre `DocumentNo` e `DocStatus` na Info Window e no relatório do módulo 07.
2. Impeça que uma visita completa seja reativada depois de 30 dias da data da visita. Onde fica essa regra: no `reActivateIt` ou no `customizeValidActions`? (Resposta: nos dois, pelo mesmo motivo do `approveIt`.)

## Referência

### Aprovação por workflow

Este módulo fez a aprovação em Java, que é fácil de testar e de ler. O iDempiere também permite aprovação configurada no workflow do documento, sem código: um nó com **Action** `User Choice` sobre a coluna `IsApproved`, um **Workflow Responsible** (um perfil ou um usuário) e condições nas transições (por exemplo, só quando `EDU_IsUrgente = Y`). As pendências aparecem para o responsável em **Workflow Activities**. É o caminho quando as regras de aprovação mudam com frequência e quem muda é um consultor, não um desenvolvedor. O **Workflow Editor** mostra os nós graficamente.

### Métodos da interface DocAction

| Método | Retorna | Quando roda |
|---|---|---|
| `prepareIt()` | status | Preparar, e antes de completar um rascunho |
| `completeIt()` | status | Completar |
| `approveIt()` / `rejectIt()` | `boolean` | Aprovar / Rejeitar |
| `voidIt()` | `boolean` | Anular |
| `closeIt()` | `boolean` | Fechar (completo e encerrado, sem volta a não ser Re-activate) |
| `reActivateIt()` | `boolean` | Reativar |
| `reverseCorrectIt()` / `reverseAccrualIt()` | `boolean` | Estornar (documentos contábeis) |
| `unlockIt()` / `invalidateIt()` | `boolean` | Destravar / Invalidar |
| `getProcessMsg()` | texto | Mensagem mostrada quando uma ação falha |

### Eventos de documento

Para reagir ao ciclo de um documento em outro plugin, use os delegates do pacote `org.adempiere.base.event.annotations.doc`, como no módulo 05: `@BeforePrepare`, `@AfterPrepare`, `@BeforeComplete`, `@AfterComplete`, `@BeforeVoid`, `@AfterVoid`, `@BeforeReactivate` e os demais. Eles só disparam porque a `MVisita` chama `fireDocValidate`.

## Próximo módulo

[10 · Experiência do usuário](https://muriloht.com/idempiere/10-experiencia-do-usuario): entrada rápida, estilos, indicadores e um formulário ZK.
