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
Antes de começar
- Módulo 08 concluído. A
MVisitatem a regra de conclusão lendo a mensagemEDU_VisitaSemLinhase a configuraçãoEDU_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,ProcessedOneProcessing; - 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 colunaDocAction.
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.
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
beforeSavee foi para oprepareIt. Antes, a regra disparava ao marcar a caixa. Agora, é validação do documento: roda quando alguém tenta preparar ou completar. ObeforeSaveficou só com o endereço padrão. prepareItvalida,completeItefetiva. OprepareItnão muda nada no registro; se algo estiver errado, devolveSTATUS_Invalidcom a mensagem emm_processMsg, e o usuário vê essa mensagem. OcompleteIté quem marcaProcessed,EDU_IsConcluidae a próxima ação.fireDocValidateantes 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
DocumentEnginedecide o status. Os métodos não chamamsetDocStatus: devolvem o status outrue/false, e o engine grava o resto. customizeValidActions(interfaceDocOptions) 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.approveItconfere 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.reverseCorrectItereverseAccrualItdevolvemfalse. 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:
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:
- Crie uma visita não urgente, sem linhas, e salve. Ela recebe um número e fica Drafted.
- Clique em Document Action, escolha Complete. O iDempiere recusa com a mensagem
EDU_VisitaSemLinhas, e a visita não completa. - Inclua uma linha e complete. O status vira Completed,
Concluídafica marcada e nada mais pode ser editado, nem nas linhas. - Document Action agora oferece Close, Void e Re-activate. Reative: a visita volta a In Progress e fica editável.
- 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.
- Entre como GardenUser. Crie uma visita urgente, com motivo e uma linha, e complete. A visita para em In Progress: o
completeItnão completa sem aprovação. Abra o Document Action de novo: não há opção de aprovar para esse perfil. - Entre como GardenAdmin (perfil GardenWorld Admin, que tem Approve own Documents). Abra a mesma visita. O Document Action agora oferece Approve e Reject.
- Aprove. O status vira Approved e a caixa Approved fica marcada.
- 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.
git add .
git commit -m "Visitas 1.0.5: visita como documento, com aprovação de urgentes"
Checkpoint
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
- Mostre
DocumentNoeDocStatusna Info Window e no relatório do módulo 07. - Impeça que uma visita completa seja reativada depois de 30 dias da data da visita. Onde fica essa regra: no
reActivateItou nocustomizeValidActions? (Resposta: nos dois, pelo mesmo motivo doapproveIt.)
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: entrada rápida, estilos, indicadores e um formulário ZK.