Módulos do guia

Nível 3 · Documentos e fluxo · Módulo 09

Documentos e aprovação

Duração estimada: 3h a 4h

Você vai sair sabendo

  • →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

Checkpoint: 0 de 7

ver .md

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çaOnde ficaO que faz
Colunas DocumentNo, DocStatus, DocAction, Processed, ProcessingTabelaGuardam número, status e a próxima ação
Workflow do tipo Document ProcessDicionárioÉ o que o botão DocAction executa
Interface DocActionClasse MUm método por ação: prepareIt, completeIt, voidIt...
DocumentEngineCoreDecide quais ações valem em cada status e chama o método certo
Interface DocOptions (opcional)Classe MMuda 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âmetroValor
Create 'DocumentNo' columnmarcado
Create 'IsApproved' columnmarcado
Create a Workflowmarcado

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:

CampoAjuste
ConcluídaRead Only marcado. Agora quem conclui é o documento
ApprovedRead Only marcado. Quem aprova é a ação Approve
Processed, ProcessedOn, ProcessingDisplayed desmarcado

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

CampoValor
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 KeyMessage TypeMessage Text
EDU_AguardandoAprovacaoInformationVisita urgente: aguardando aprovação.
EDU_SemPermissaoAprovarErrorSeu 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.

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.

OndeTroque 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:

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.

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:

TypeSQL Expression/Statement
SQL Statementos dois UPDATE do passo 6

Gere o 2Pack_1.0.5.zip.

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

Checkpoint

0 de 7 itens

Erros comuns

SintomaCausa provávelCorreção
Create/Complete Table diz que a tabela já existeRodou pelo menu, não a partir do registro da tabelaAbra EDU_Visita e rode pelo botão de processos da aba Table
Synchronize Column falha em DocStatusColuna obrigatória sem valor padrãoConfira Default Logic DR na coluna
O botão Document Action não faz nadaColuna DocAction sem processo, ou workflow sem acessoConfira o Process da coluna; rode Role Access Update
"Persistent Object not DocAction"A factory devolveu outra classe, ou a MVisita não implementa DocActionConfira a classe e reinicie
Visita completa continua editávelFaltou setProcessed(true) no completeItPasso 4
Linhas editáveis numa visita completaRead Only Logic da aba Linhas ainda com @EDU_IsConcluida@Passo 2
GardenAdmin não vê ApproveVisita não urgente, já aprovada, ou fora de In ProgressConfira as condições do customizeValidActions
Números saem sem o prefixoPrefixo preenchido depois dos números já geradosSó 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étodoRetornaQuando roda
prepareIt()statusPreparar, e antes de completar um rascunho
completeIt()statusCompletar
approveIt() / rejectIt()booleanAprovar / Rejeitar
voidIt()booleanAnular
closeIt()booleanFechar (completo e encerrado, sem volta a não ser Re-activate)
reActivateIt()booleanReativar
reverseCorrectIt() / reverseAccrualIt()booleanEstornar (documentos contábeis)
unlockIt() / invalidateIt()booleanDestravar / Invalidar
getProcessMsg()textoMensagem 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: entrada rápida, estilos, indicadores e um formulário ZK.