Nível 1 · Código · Módulo 05
Regras de negócio: callout e event handler
Duração estimada: 2h a 3h
Você vai sair sabendo
- →Evoluir o Dicionário e distribuir a mudança como um 2Pack novo
- →Reagir ao que o usuário digita com um callout
- →Saber quando uma regra vai no callout e quando vai no beforeSave
- →Reagir a gravações de tabelas do core com um event handler
Checkpoint: 0 de 6
Antes de começar
- Módulo 04 concluído: o plugin
com.gardenworld.visitassobe comoACTIVE, e aMVisitabarra a conclusão sem linhas. - Servidor em modo Debug. Neste módulo você vai reiniciar algumas vezes.
O que você vai construir
Três comportamentos novos, cada um no lugar certo:
- O técnico precisa saber aonde ir. A visita ganha o campo Endereço. Ao escolher o cliente, o endereço de entrega dele aparece na hora (callout).
- O endereço nunca fica vazio, mesmo quando a visita é criada por código, sem tela (
beforeSave). - Cliente com visita em aberto não pode ser desativado. A regra fica na tabela de parceiros, que é do core (event handler).
Onde cada regra mora
Este é o conceito central do módulo. Antes de escrever código, decida onde a regra vive:
| Mecanismo | Roda quando | Roda sem tela? | Use para |
|---|---|---|---|
| Lógicas do Dicionário (módulo 02) | O usuário mexe na janela | Não | Mostrar, esconder, travar, valor inicial |
| Callout | O usuário muda um campo na janela, antes de salvar | Não | Conveniência: preencher outros campos na hora |
beforeSave da classe M (módulo 04) | Qualquer gravação da sua tabela | Sim | Regras e valores garantidos da sua tabela |
| Event handler | Qualquer gravação de qualquer tabela, inclusive do core | Sim | Reagir a tabelas que não são suas |
A pergunta que resolve quase todos os casos: "se esse registro for criado por uma importação ou por uma API, a regra precisa valer?" Se sim, callout não serve. Ele é para o usuário, não para o dado.
Passo a passo
1. Uma coluna nova e o pacote 1.0.1
Como System, crie uma validação dinâmica (janela Validation Rules, como no módulo 01):
| Campo | Valor |
|---|---|
| Name | EDU - Endereço do Cliente |
| Type | SQL |
| Validation code | C_BPartner_Location.C_BPartner_ID=@C_BPartner_ID@ |
| Entity Type | EDU |
Na tabela EDU_Visita, crie a coluna e sincronize:
| DB Column Name | Reference | Detalhes |
|---|---|---|
C_BPartner_Location_ID | Table Direct | Dynamic Validation: EDU - Endereço do Cliente |
O elemento C_BPartner_Location_ID já existe no core (é o mesmo Partner Location dos pedidos), então não é preciso criar um. Não marque Mandatory: as visitas que já existem não têm endereço.
Na janela Window, Tab and Field, aba Visita, rode Create Fields e posicione o campo logo abaixo do cliente no Tab Editor.
Agora distribua a mudança. Abra a janela Pack Out, no registro EDU_Visitas do módulo 03:
- Na aba de detalhes, inclua a validação nova com Seq
35(depois da outra validação, antes das tabelas). - No cabeçalho, mude Package Version para
1.0.1. - Exporte e salve o zip em
com.gardenworld.visitas/META-INF/2Pack_1.0.1.zip.
Não apague o 2Pack_1.0.0.zip. Um ambiente novo aplica os dois, em ordem; o seu ambiente, que já tem o 1.0.0, aplica só o 1.0.1. É o mesmo raciocínio dos scripts de migração do core.
2. Regenere as classes
Rode o Generate Model de novo, com os mesmos parâmetros do módulo 04. A X_EDU_Visita ganha getC_BPartner_Location_ID() e setC_BPartner_Location_ID(). A MVisita não é tocada: é por isso que a regra fica nela.
Aperte F5 no plugin e confira que tudo compila.
3. O endereço padrão, num lugar só
A mesma lógica ("qual é o endereço padrão deste cliente?") vai ser usada pelo callout e pelo beforeSave. Escreva uma vez, na MVisita:
/** 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();
}
firstId() devolve -1 quando não encontra nada. Adicione o import org.compiere.model.MBPartnerLocation.
Agora garanta o valor no beforeSave, antes da regra do módulo 04:
@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()));
}
if (isEDU_IsConcluida()
&& is_ValueChanged(COLUMNNAME_EDU_IsConcluida)
&& getLinhas().isEmpty()) {
log.saveError("Error", "Inclua pelo menos uma linha antes de concluir a visita");
return false;
}
return true;
}
A segunda condição cobre quem troca o cliente e esquece o endereço: o endereço antigo seria de outro parceiro. O setter gerado grava null quando recebe um valor menor que 1, então o -1 vira campo vazio.
4. O callout
Conceito: um callout é uma classe que o iDempiere chama quando o usuário muda um campo na janela. Ela recebe a aba (GridTab) e pode ler e alterar os outros campos antes de o registro ser salvo. Nada vai para o banco até o usuário salvar.
Crie com.gardenworld.visitas.callout.CalloutVisitaCliente:
package com.gardenworld.visitas.callout;
import java.util.Properties;
import org.adempiere.base.IColumnCallout;
import org.adempiere.base.annotation.Callout;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;
import com.gardenworld.visitas.model.MVisita;
@Callout(tableName = MVisita.Table_Name, columnName = MVisita.COLUMNNAME_C_BPartner_ID)
public class CalloutVisitaCliente implements IColumnCallout {
@Override
public String start(Properties ctx, int WindowNo, GridTab mTab, GridField mField,
Object value, Object oldValue) {
Integer enderecoId = null;
if (value instanceof Integer bpartnerId && bpartnerId > 0) {
int id = MVisita.getEnderecoPadrao(ctx, bpartnerId, null);
if (id > 0)
enderecoId = id;
}
mTab.setValue(MVisita.COLUMNNAME_C_BPartner_Location_ID, enderecoId);
return "";
}
}
O retorno é a mensagem de erro: "" significa que deu tudo certo. Repare que o callout reaproveita a regra da MVisita em vez de repeti-la, e usa null como transação: ainda não existe gravação em andamento.
Falta a factory, igual à de modelo do módulo 04. Crie com.gardenworld.visitas.VisitasCalloutFactory:
package com.gardenworld.visitas;
import org.adempiere.base.AnnotationBasedColumnCalloutFactory;
import org.adempiere.base.IColumnCalloutFactory;
import org.osgi.service.component.annotations.Component;
@Component(immediate = true, service = IColumnCalloutFactory.class)
public class VisitasCalloutFactory extends AnnotationBasedColumnCalloutFactory {
@Override
protected String[] getPackages() {
return new String[] {"com.gardenworld.visitas.callout"};
}
}
5. O event handler
Conceito: toda gravação no iDempiere dispara eventos (antes de inserir, depois de alterar, antes de excluir...). Um event handler escuta os eventos de uma tabela e pode barrar a gravação lançando uma exceção. Funciona para qualquer tabela, e é assim que um plugin muda o comportamento do core sem tocar nele.
Primeiro, o plugin precisa enxergar a API de eventos do OSGi. No MANIFEST.MF, acrescente ao Import-Package:
Import-Package: org.osgi.framework,
org.osgi.service.component.annotations;resolution:=optional,
org.osgi.service.event
Crie com.gardenworld.visitas.event.ClienteComVisitaAberta:
package com.gardenworld.visitas.event;
import org.adempiere.base.annotation.EventTopicDelegate;
import org.adempiere.base.annotation.ModelEventTopic;
import org.adempiere.base.event.annotations.ModelEventDelegate;
import org.adempiere.base.event.annotations.po.BeforeChange;
import org.adempiere.exceptions.AdempiereException;
import org.compiere.model.MBPartner;
import org.compiere.model.Query;
import org.osgi.service.event.Event;
import com.gardenworld.visitas.model.MVisita;
@EventTopicDelegate
@ModelEventTopic(modelClass = MBPartner.class)
public class ClienteComVisitaAberta extends ModelEventDelegate<MBPartner> {
public ClienteComVisitaAberta(MBPartner po, Event event) {
super(po, event);
}
@BeforeChange
public void impedirDesativacao() {
MBPartner cliente = getModel();
if (cliente.isActive() || !cliente.is_ValueChanged(MBPartner.COLUMNNAME_IsActive))
return;
boolean temVisitaAberta = new Query(cliente.getCtx(), MVisita.Table_Name,
"C_BPartner_ID=? AND EDU_IsConcluida='N'", cliente.get_TrxName())
.setParameters(cliente.getC_BPartner_ID())
.match();
if (temVisitaAberta)
throw new AdempiereException("Este cliente tem visitas técnicas em aberto. Conclua as visitas antes de desativá-lo.");
}
}
E o gerenciador que registra os delegates do pacote, com.gardenworld.visitas.VisitasEventManager:
package com.gardenworld.visitas;
import org.adempiere.base.AnnotationBasedEventManager;
import org.osgi.service.component.annotations.Component;
@Component(immediate = true)
public class VisitasEventManager extends AnnotationBasedEventManager {
@Override
public String[] getPackages() {
return new String[] {"com.gardenworld.visitas.event"};
}
}
O padrão é o mesmo das factories: a classe base varre o pacote atrás de @EventTopicDelegate, e a anotação no método (@BeforeChange) diz em qual evento ele roda. Uma instância do delegate é criada a cada evento, com o registro em getModel().
Duas regras de ouro de event handler:
- Use sempre
get_TrxName()do registro nas consultas. O handler roda dentro da transação da gravação. - Seja rápido. Um handler em
C_BPartnerroda em toda gravação de parceiro do sistema inteiro, inclusive nas do core. Saia cedo (return) quando o evento não interessa, como noifdo início.
6. Teste
Reinicie o servidor: há factories e um gerenciador novos. No console, confira ss com.gardenworld e, na aba Package Installation da janela Pack In, o registro da versão 1.0.1.
Como GardenAdmin:
- Callout: crie uma visita e escolha o cliente
Joe Block. O endereço aparece antes de salvar. Troque o cliente: o endereço troca junto. - Validação: abra a lista do campo Endereço. Só aparecem endereços do cliente escolhido.
- beforeSave: apague o endereço e salve. Ao recarregar o registro (F5 na janela), o endereço padrão voltou.
- Event handler: deixe uma visita de
Joe Blocksem marcar Concluída. Abra a janela Business Partner, desmarque Active emJoe Blocke salve. A gravação é recusada com a sua mensagem.
Coloque breakpoints no callout e no delegate e repita os testes para ver a ordem das chamadas.
7. Commit
cd ~/sources/guia-idempiere-projeto
git add .
git commit -m "Visitas 1.0.1: endereço do cliente, callout e bloqueio de desativação"
Checkpoint
Erros comuns
| Sintoma | Causa provável | Correção |
|---|---|---|
| O callout não é chamado | Factory não registrada ou pacote errado em getPackages() | Confira OSGI-INF/ e o nome do pacote; reinicie |
| O callout roda, mas o endereço não aparece | O campo Endereço não está na aba | Rode Create Fields na aba Visita |
O plugin fica INSTALLED depois deste módulo | Faltou org.osgi.service.event no Import-Package | Passo 5 |
| O event handler nunca dispara | O VisitasEventManager não foi registrado | Confira OSGI-INF/ e reinicie |
| A desativação passa mesmo com visita aberta | Visita concluída, ou de outro cliente | Confira EDU_IsConcluida no banco |
| O pacote 1.0.1 não é aplicado | O nome não segue 2Pack_x.y.z.zip, ou a versão do cabeçalho do Pack Out ficou 1.0.0 | Confira o nome do arquivo e a versão |
| Lista de endereços vazia | A validação usa @C_BPartner_ID@ e o cliente ainda não foi escolhido | Escolha o cliente primeiro |
Desafio
Duas regras para colocar no lugar certo:
- Ao marcar Urgente na tela, preencha o Motivo da Urgência com o texto
Descreva o problema:se ele estiver vazio. Callout ou beforeSave? - Impeça que um produto seja desativado se ele aparece numa linha de visita em aberto. Qual tabela o handler escuta, e qual consulta ele faz?
Referência
Eventos de modelo
| Anotação | Momento |
|---|---|
@BeforeNew | Antes do INSERT |
@AfterNew | Depois do INSERT, na mesma transação |
@BeforeChange | Antes do UPDATE |
@AfterChange | Depois do UPDATE, na mesma transação |
@BeforeDelete | Antes do DELETE |
@AfterDelete | Depois do DELETE |
@PostCreate, @PostUpdate, @PostDelete | Depois do commit, fora da transação e de forma assíncrona |
Os de documento (completar, anular, estornar) estão no pacote org.adempiere.base.event.annotations.doc e aparecem no módulo 09.
Use After* para mexer em outros registros dentro da mesma transação (se algo falhar, tudo volta). Use Post* para efeitos externos que só devem acontecer se a gravação foi confirmada, como avisar outro sistema (módulo 11).
Ordem de execução numa gravação
beforeSaveda classe M- Event handlers
Before* INSERT/UPDATEno bancoafterSaveda classe M- Event handlers
After* - Commit
- Event handlers
Post*
Callout: o que dá para fazer com o GridTab
| Chamada | Para quê |
|---|---|
mTab.getValue("Coluna") | Ler outro campo da aba |
mTab.setValue("Coluna", valor) | Alterar outro campo |
Env.getContextAsInt(ctx, WindowNo, "Coluna") | Ler o contexto da janela, inclusive de abas acima |
return "mensagem" | Mostrar um erro ao usuário |
Callout é código de tela: não acesse o banco em excesso e não grave registros nele. Quem grava é o beforeSave ou um processo.
Próximo módulo
06 · Processos: gerar visitas de manutenção em lote, com parâmetros, a partir do menu.