Custom Processes and Forms
Overview
- What you’ll learn:
- How to create custom server processes using SvrProcess, including the lifecycle (prepare/doIt), parameters, logging, and return messages
- How to register processes in the Application Dictionary and make them accessible from the menu or toolbar
- How to build custom interactive forms using IFormController with ZK components, layout, and lifecycle management
- Prerequisites: Lessons 1–22 (especially Lessons 19 and 21: Extension Points and Creating Your First Plugin)
- Estimated reading time: 26 minutes
Introduction
Event handlers react to data changes initiated by users or other processes. But what about operations that users initiate deliberately — generating a batch of invoices, recalculating prices across a product catalog, importing data from an external file, or running a custom report? These are the domain of processes. And what about specialized user interfaces that do not fit the standard window/tab/field paradigm — a visual configuration tool, a drag-and-drop scheduler, or a multi-step wizard? These are the domain of forms.
Processes and forms are the two remaining major extension points in iDempiere’s plugin architecture. This lesson covers both in depth, from the Java implementation to the Application Dictionary registration to the deployment workflow.
Custom Processes: SvrProcess
A process in iDempiere is a server-side operation that can be triggered from the menu, from a toolbar button, or programmatically from other code. Every custom process extends the org.compiere.process.SvrProcess base class.
The SvrProcess Lifecycle
When a process is executed, iDempiere calls two methods in sequence:
prepare()— Called first. This is where you read and store the process parameters that the user provided. You should not perform any business logic here — just parameter parsing.doIt()— Called afterprepare()completes. This is where you implement the actual business logic. The return value is a message string displayed to the user when the process finishes.
package com.example.myplugin.process;
import org.compiere.process.SvrProcess;
public class MyCustomProcess extends SvrProcess {
@Override
protected void prepare() {
// Read parameters here
}
@Override
protected String doIt() throws Exception {
// Business logic here
return "Process completed successfully";
}
}
Process Parameters
Processes can accept parameters that the user fills in before execution. These parameters are defined in the Application Dictionary (AD_Process_Para) and passed to your process at runtime. In the prepare() method, you read them using getParameter():
package com.example.myplugin.process;
import java.math.BigDecimal;
import java.sql.Timestamp;
import org.compiere.process.ProcessInfoParameter;
import org.compiere.process.SvrProcess;
public class GenerateInvoicesProcess extends SvrProcess {
private int p_C_BPartner_ID = 0;
private Timestamp p_DateFrom = null;
private Timestamp p_DateTo = null;
private BigDecimal p_MinAmount = BigDecimal.ZERO;
private boolean p_IsTestRun = false;
@Override
protected void prepare() {
// Iterate through all parameters
ProcessInfoParameter[] params = getParameter();
for (ProcessInfoParameter param : params) {
String name = param.getParameterName();
if ("C_BPartner_ID".equals(name)) {
p_C_BPartner_ID = param.getParameterAsInt();
} else if ("DateFrom".equals(name)) {
p_DateFrom = param.getParameterAsTimestamp();
} else if ("DateTo".equals(name)) {
p_DateTo = param.getParameterAsTimestamp();
} else if ("MinAmount".equals(name)) {
p_MinAmount = param.getParameterAsBigDecimal();
} else if ("IsTestRun".equals(name)) {
p_IsTestRun = param.getParameterAsBoolean();
}
}
// You can also access the record ID if the process was
// triggered from a specific record's toolbar
int recordId = getRecord_ID();
}
@Override
protected String doIt() throws Exception {
// Use the parsed parameters
// (implementation continues below)
return "";
}
}
The addLog() Method: User Feedback
Long-running processes should provide feedback to the user about their progress and results. The addLog() method writes messages to the process log, which the user can view during and after execution:
@Override
protected String doIt() throws Exception {
int invoicesGenerated = 0;
int errors = 0;
// Query for uninvoiced orders
String sql = "SELECT C_Order_ID FROM C_Order "
+ "WHERE IsSOTrx='Y' AND DocStatus='CO' "
+ "AND IsInvoiced='N'";
if (p_C_BPartner_ID > 0) {
sql += " AND C_BPartner_ID=" + p_C_BPartner_ID;
}
if (p_DateFrom != null) {
sql += " AND DateOrdered >= " + DB.TO_DATE(p_DateFrom);
}
if (p_DateTo != null) {
sql += " AND DateOrdered <= " + DB.TO_DATE(p_DateTo);
}
// Process each order
java.sql.PreparedStatement pstmt = null;
java.sql.ResultSet rs = null;
try {
pstmt = DB.prepareStatement(sql, get_TrxName());
rs = pstmt.executeQuery();
while (rs.next()) {
int orderId = rs.getInt("C_Order_ID");
try {
if (p_IsTestRun) {
// Log what would happen without actually doing it
addLog(0, null, null,
"Would generate invoice for Order #"
+ orderId);
} else {
generateInvoice(orderId);
invoicesGenerated++;
addLog(0, null, null,
"Invoice generated for Order #" + orderId);
}
} catch (Exception e) {
errors++;
addLog(0, null, null,
"ERROR processing Order #" + orderId
+ ": " + e.getMessage());
}
}
} finally {
DB.close(rs, pstmt);
}
// Return a summary message
String msg = invoicesGenerated + " invoices generated";
if (errors > 0) {
msg += ", " + errors + " errors";
}
if (p_IsTestRun) {
msg = "TEST RUN: " + msg;
}
return msg;
}
private void generateInvoice(int orderId) {
// Invoice generation logic here
// This would use MOrder.createInvoice() or similar
}
The addLog() Overloads
The addLog() method has several overloads for different types of log entries:
// Simple text message
addLog(0, null, null, "Processing started");
// Message with a date
addLog(0, new Timestamp(System.currentTimeMillis()),
null, "Batch started at this time");
// Message with a numeric value
addLog(0, null, new BigDecimal("1500.00"),
"Total amount processed");
// Message with record reference (creates a clickable link in the log)
addLog(0, null, null, "Created Invoice",
MTable.getTable_ID("C_Invoice"), invoiceId);
Return Messages
The string returned by doIt() is displayed to the user as the process result. iDempiere recognizes special message prefixes:
"@Error@"— Indicates the process failed. The message is displayed with an error indicator. The transaction is rolled back."@OK@"— Indicates success (the default if no prefix is used)."@ProcessOK@"— Success with the standard “Process completed” message.
@Override
protected String doIt() throws Exception {
try {
int count = processRecords();
if (count == 0) {
return "@Error@ No records found matching the criteria";
}
return "@OK@ " + count + " records processed successfully";
} catch (Exception e) {
log.severe(e.getMessage());
return "@Error@ " + e.getMessage();
}
}
Accessing Context Information
Within a process, you can access the user’s context (client, organization, role, etc.):
@Override
protected String doIt() throws Exception {
// Get context values
Properties ctx = getCtx();
int clientId = Env.getAD_Client_ID(ctx);
int orgId = Env.getAD_Org_ID(ctx);
int userId = Env.getAD_User_ID(ctx);
int roleId = Env.getAD_Role_ID(ctx);
// Get the transaction name (for database operations)
String trxName = get_TrxName();
// Get the process info (contains the AD_Process_ID,
// AD_PInstance_ID, etc.)
ProcessInfo pi = getProcessInfo();
return "";
}
Registering a Process in the Application Dictionary
A process class on its own is not accessible to users. You must register it in the Application Dictionary so that iDempiere knows about it and can present it in the UI.
Step 1: Create the AD_Process Record
Navigate to System Admin > General Rules > System Rules > Report & Process (or search for “Report & Process”). Create a new record:
- Search Key (Value): A unique identifier (e.g.,
GenerateInvoices) - Name: The display name (e.g.,
Generate Invoices from Orders) - Description: What the process does
- Active: Yes
- Entity Type: User Maintained (for custom code)
- Data Access Level: Client+Organization (typical for business processes)
- Type: Java
- Classname: The fully qualified class name (e.g.,
com.example.myplugin.process.GenerateInvoicesProcess)
Step 2: Define Process Parameters (AD_Process_Para)
Switch to the Parameter tab and add a record for each parameter your process accepts:
| Field | Example for C_BPartner_ID | Example for DateFrom |
|---|---|---|
| Name | Business Partner | Date From |
| DB Column Name | C_BPartner_ID | DateFrom |
| Reference | Table Direct | Date |
| Mandatory | No | No |
| Sequence | 10 | 20 |
| Default Logic | (empty) | @#Date@ |
The parameter names in AD_Process_Para must match the names you use in getParameterName() in your prepare() method. The Reference type determines what kind of input widget the user sees (text field, date picker, dropdown, lookup, etc.).
Step 3: Add a Menu Entry
To make the process accessible from the menu, navigate to System Admin > General Rules > System Rules > Menu. Create a new menu entry:
- Name: Generate Invoices from Orders
- Action: Process
- Process: (select your AD_Process record)
- Summary Level: No (this is a leaf item, not a folder)
Place the menu entry under the appropriate parent folder in the menu tree.
Step 4: Grant Role Access
Navigate to the Role window, select the role that should have access, switch to the Process Access tab, and add your process with Read/Write access.
Packaging with 2Pack
For distribution, use 2Pack (covered in Lesson 20) to export the AD_Process, AD_Process_Para, AD_Menu, and role access records. Include the 2Pack file in your plugin’s migration/ directory so it is automatically applied during installation.
Registering a Process via IProcessFactory
When iDempiere needs to instantiate a process class, it uses the IProcessFactory service (covered in Lesson 19). If your process class is in your plugin bundle, you must register an IProcessFactory so the framework can find and instantiate it:
package com.example.myplugin.factory;
import org.adempiere.base.IProcessFactory;
import org.compiere.process.ProcessCall;
public class MyProcessFactory implements IProcessFactory {
@Override
public ProcessCall newProcessInstance(String className) {
switch (className) {
case "com.example.myplugin.process.GenerateInvoicesProcess":
return new com.example.myplugin.process
.GenerateInvoicesProcess();
case "com.example.myplugin.process.RecalculatePrices":
return new com.example.myplugin.process
.RecalculatePrices();
default:
return null; // Defer to the next factory
}
}
}
Register this factory as a DS component as described in Lesson 19. Without this factory registration, iDempiere’s default factory will not be able to find your process class (because it lives in a separate OSGi bundle with its own classloader).
Custom Forms: IFormController
Forms are interactive user interfaces for tasks that require a custom layout beyond what the standard window/tab/field model provides. Examples include visual scheduling tools, configuration wizards, data import screens, and specialized search interfaces.
The IFormController Interface
In the ZK web client, custom forms implement the org.adempiere.webui.panel.IFormController interface:
public interface IFormController {
/**
* Initialize the form. This is called when the form is first opened.
* @param WindowNo the window number assigned to this form
* @param panel the ADForm panel that hosts this form
*/
public void initForm();
/**
* Get the form panel component.
* @return the ZK Component that contains the form's UI
*/
public Component getComponent();
}
Implementing a Custom Form
A custom form typically extends a ZK layout component and implements IFormController. Here is a complete example of a simple data entry form:
package com.example.myplugin.form;
import java.util.logging.Level;
import org.adempiere.webui.component.Button;
import org.adempiere.webui.component.Grid;
import org.adempiere.webui.component.GridFactory;
import org.adempiere.webui.component.Label;
import org.adempiere.webui.component.Listbox;
import org.adempiere.webui.component.ListItem;
import org.adempiere.webui.component.Row;
import org.adempiere.webui.component.Rows;
import org.adempiere.webui.component.Textbox;
import org.adempiere.webui.panel.ADForm;
import org.adempiere.webui.panel.IFormController;
import org.compiere.util.CLogger;
import org.compiere.util.Env;
import org.zkoss.zk.ui.Component;
import org.zkoss.zk.ui.event.Event;
import org.zkoss.zk.ui.event.EventListener;
import org.zkoss.zk.ui.event.Events;
import org.zkoss.zul.Borderlayout;
import org.zkoss.zul.Center;
import org.zkoss.zul.North;
import org.zkoss.zul.South;
import org.zkoss.zul.Separator;
public class SimpleDataEntryForm implements IFormController,
EventListener<Event> {
private static final CLogger logger =
CLogger.getCLogger(SimpleDataEntryForm.class);
private ADForm adForm;
private Borderlayout mainLayout;
private Textbox nameField;
private Textbox descriptionField;
private Listbox categoryList;
private Button submitButton;
private Button clearButton;
private Label statusLabel;
@Override
public void initForm() {
buildUI();
}
@Override
public Component getComponent() {
return mainLayout;
}
public void setADForm(ADForm form) {
this.adForm = form;
}
private void buildUI() {
// Main layout using BorderLayout
mainLayout = new Borderlayout();
mainLayout.setWidth("100%");
mainLayout.setHeight("100%");
// North section: title and instructions
North north = new North();
mainLayout.appendChild(north);
Label titleLabel = new Label("Custom Data Entry Form");
titleLabel.setStyle(
"font-size: 16px; font-weight: bold; padding: 10px;");
north.appendChild(titleLabel);
// Center section: input form
Center center = new Center();
mainLayout.appendChild(center);
Grid grid = GridFactory.newGridLayout();
grid.setWidth("500px");
grid.setStyle("margin: 20px;");
center.appendChild(grid);
Rows rows = grid.newRows();
// Name field
Row nameRow = rows.newRow();
nameRow.appendChild(new Label("Name:"));
nameField = new Textbox();
nameField.setWidth("300px");
nameField.setMaxlength(100);
nameRow.appendChild(nameField);
// Description field
Row descRow = rows.newRow();
descRow.appendChild(new Label("Description:"));
descriptionField = new Textbox();
descriptionField.setWidth("300px");
descriptionField.setRows(3);
descriptionField.setMultiline(true);
descRow.appendChild(descriptionField);
// Category dropdown
Row catRow = rows.newRow();
catRow.appendChild(new Label("Category:"));
categoryList = new Listbox();
categoryList.setMold("select");
categoryList.appendItem("Select...", "");
categoryList.appendItem("Category A", "A");
categoryList.appendItem("Category B", "B");
categoryList.appendItem("Category C", "C");
catRow.appendChild(categoryList);
// Separator
Row sepRow = rows.newRow();
sepRow.appendChild(new Separator());
sepRow.appendChild(new Separator());
// Buttons
Row buttonRow = rows.newRow();
submitButton = new Button("Submit");
submitButton.addEventListener(Events.ON_CLICK, this);
clearButton = new Button("Clear");
clearButton.addEventListener(Events.ON_CLICK, this);
org.zkoss.zul.Hbox buttonBox = new org.zkoss.zul.Hbox();
buttonBox.appendChild(submitButton);
buttonBox.appendChild(clearButton);
buttonRow.appendChild(new Label(""));
buttonRow.appendChild(buttonBox);
// South section: status bar
South south = new South();
mainLayout.appendChild(south);
statusLabel = new Label("Ready");
statusLabel.setStyle("padding: 5px; color: #666;");
south.appendChild(statusLabel);
}
@Override
public void onEvent(Event event) throws Exception {
Component comp = event.getTarget();
if (comp == submitButton) {
handleSubmit();
} else if (comp == clearButton) {
handleClear();
}
}
private void handleSubmit() {
// Validate input
String name = nameField.getValue();
if (name == null || name.trim().isEmpty()) {
statusLabel.setValue("Error: Name is required");
statusLabel.setStyle("padding: 5px; color: red;");
return;
}
ListItem selectedCategory = categoryList.getSelectedItem();
if (selectedCategory == null
|| "".equals(selectedCategory.getValue())) {
statusLabel.setValue("Error: Please select a category");
statusLabel.setStyle("padding: 5px; color: red;");
return;
}
// Process the data
String description = descriptionField.getValue();
String category = (String) selectedCategory.getValue();
try {
// Here you would save to the database, call a process,
// or perform other business logic
logger.info("Form submitted: name=" + name
+ ", description=" + description
+ ", category=" + category);
statusLabel.setValue("Data submitted successfully: "
+ name);
statusLabel.setStyle("padding: 5px; color: green;");
// Optionally clear the form after successful submission
handleClear();
} catch (Exception e) {
logger.log(Level.SEVERE, "Error submitting form", e);
statusLabel.setValue("Error: " + e.getMessage());
statusLabel.setStyle("padding: 5px; color: red;");
}
}
private void handleClear() {
nameField.setValue("");
descriptionField.setValue("");
categoryList.setSelectedIndex(0);
}
}
ZK Components Reference
iDempiere wraps many ZK components in its own classes (in the org.adempiere.webui.component package) that provide additional functionality. Here are the most commonly used ones:
| Component | Purpose | Key Methods |
|---|---|---|
Textbox |
Single or multi-line text input | getValue(), setValue(), setMaxlength() |
NumberBox |
Numeric input with formatting | getValue(), setValue() |
Datebox |
Date picker | getValue(), setValue() |
Listbox |
Dropdown or list selection | appendItem(), getSelectedItem() |
Button |
Clickable button | addEventListener(), setEnabled() |
Label |
Read-only text display | setValue() |
Grid |
Two-column form layout | newRows(), via GridFactory |
Tab/Tabbox |
Tabbed interface | appendChild() |
Checkbox |
Boolean toggle | isChecked(), setChecked() |
WListbox |
Data grid with sortable columns | setData(), getSelectedRowKey() |
Form Layout
ZK provides several layout containers for organizing form components:
- Borderlayout — Divides space into North, South, East, West, and Center regions. Ideal for full-page forms with a header, footer, and main content area.
- Grid — Organizes components in rows and columns. Best for label-field pairs in a form layout.
- Hbox/Vbox — Horizontal or vertical arrangement of components. Good for button bars or simple groupings.
- Tabbox — Tabbed panels. Useful when a form has multiple sections.
Form Lifecycle
A form’s lifecycle in iDempiere follows these steps:
- User clicks a menu item or toolbar button that is linked to a Form in the AD
- iDempiere calls
IFormFactoryto instantiate the form class - The
ADFormpanel callssetADForm()if the method exists - The
ADFormpanel callsinitForm() - The form’s UI components are rendered in the user’s browser
- User interactions trigger event listeners (onClick, onChange, etc.)
- When the user closes the form tab, any cleanup should happen
Registering Forms in the Application Dictionary
Similar to processes, forms must be registered in the AD to be accessible.
Step 1: Create the AD_Form Record
Navigate to System Admin > General Rules > System Rules > Form. Create a new record:
- Name: Simple Data Entry Form
- Description: A custom data entry form for demonstration
- Classname:
com.example.myplugin.form.SimpleDataEntryForm - Entity Type: User Maintained
- Active: Yes
Step 2: Add a Menu Entry
Create a menu entry with:
- Action: Form
- Form: (select your AD_Form record)
Step 3: Register IFormFactory
Create a factory to instantiate your form:
package com.example.myplugin.factory;
import org.adempiere.base.IFormFactory;
public class MyFormFactory implements IFormFactory {
@Override
public Object newFormInstance(String formClassName) {
if ("com.example.myplugin.form.SimpleDataEntryForm"
.equals(formClassName)) {
return new com.example.myplugin.form.SimpleDataEntryForm();
}
return null;
}
}
Register this in component.xml:
<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
name="com.example.myplugin.factory.MyFormFactory">
<implementation class="com.example.myplugin.factory.MyFormFactory"/>
<service>
<provide interface="org.adempiere.base.IFormFactory"/>
</service>
<property name="service.ranking" type="Integer" value="100"/>
</scr:component>
Practical Example: A Report Generation Process
Let us build a complete process that queries data and generates a summary report. This process finds all overdue invoices for a given business partner and creates a log summary:
package com.example.myplugin.process;
import java.math.BigDecimal;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.util.logging.Level;
import org.compiere.model.MBPartner;
import org.compiere.process.ProcessInfoParameter;
import org.compiere.process.SvrProcess;
import org.compiere.util.DB;
import org.compiere.util.Env;
public class OverdueInvoiceReport extends SvrProcess {
private int p_C_BPartner_ID = 0;
private int p_DaysOverdue = 30;
@Override
protected void prepare() {
ProcessInfoParameter[] params = getParameter();
for (ProcessInfoParameter param : params) {
String name = param.getParameterName();
if ("C_BPartner_ID".equals(name)) {
p_C_BPartner_ID = param.getParameterAsInt();
} else if ("DaysOverdue".equals(name)) {
p_DaysOverdue = param.getParameterAsInt();
}
}
}
@Override
protected String doIt() throws Exception {
addLog("Starting Overdue Invoice Report");
addLog("Parameters: Days Overdue = " + p_DaysOverdue
+ (p_C_BPartner_ID > 0
? ", BPartner ID = " + p_C_BPartner_ID : ""));
StringBuilder sql = new StringBuilder();
sql.append("SELECT i.C_Invoice_ID, i.DocumentNo, ")
.append("i.DateInvoiced, i.GrandTotal, i.OpenAmt, ")
.append("bp.Name AS BPartnerName, ")
.append("EXTRACT(DAY FROM now() - i.DateInvoiced) ")
.append("AS DaysOutstanding ")
.append("FROM C_Invoice i ")
.append("JOIN C_BPartner bp ")
.append("ON i.C_BPartner_ID = bp.C_BPartner_ID ")
.append("WHERE i.IsSOTrx = 'Y' ")
.append("AND i.IsPaid = 'N' ")
.append("AND i.DocStatus = 'CO' ")
.append("AND i.AD_Client_ID = ? ")
.append("AND EXTRACT(DAY FROM now() - i.DateInvoiced) > ? ");
if (p_C_BPartner_ID > 0) {
sql.append("AND i.C_BPartner_ID = ? ");
}
sql.append("ORDER BY DaysOutstanding DESC");
int count = 0;
BigDecimal totalOverdue = BigDecimal.ZERO;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
pstmt = DB.prepareStatement(
sql.toString(), get_TrxName());
int idx = 1;
pstmt.setInt(idx++, Env.getAD_Client_ID(getCtx()));
pstmt.setInt(idx++, p_DaysOverdue);
if (p_C_BPartner_ID > 0) {
pstmt.setInt(idx++, p_C_BPartner_ID);
}
rs = pstmt.executeQuery();
addLog("-----------------------------------");
addLog("Invoice# | Partner | Amount | Days");
addLog("-----------------------------------");
while (rs.next()) {
String docNo = rs.getString("DocumentNo");
String bpName = rs.getString("BPartnerName");
BigDecimal openAmt = rs.getBigDecimal("OpenAmt");
int days = rs.getInt("DaysOutstanding");
addLog(docNo + " | " + bpName + " | "
+ openAmt + " | " + days + " days");
totalOverdue = totalOverdue.add(openAmt);
count++;
}
} finally {
DB.close(rs, pstmt);
}
addLog("-----------------------------------");
addLog("Total overdue invoices: " + count);
addLog("Total overdue amount: " + totalOverdue);
if (count == 0) {
return "@OK@ No overdue invoices found";
}
return "@OK@ Found " + count + " overdue invoices "
+ "totaling " + totalOverdue;
}
}
Plugin Structure: Bringing Processes and Forms Together
A complete plugin that includes both processes and forms has this structure:
com.example.myplugin/
├── META-INF/
│ └── MANIFEST.MF
├── OSGI-INF/
│ ├── ProcessFactory.xml
│ ├── FormFactory.xml
│ └── EventHandler.xml
├── src/
│ └── com/example/myplugin/
│ ├── Activator.java
│ ├── factory/
│ │ ├── MyProcessFactory.java
│ │ └── MyFormFactory.java
│ ├── process/
│ │ ├── GenerateInvoicesProcess.java
│ │ └── OverdueInvoiceReport.java
│ ├── form/
│ │ └── SimpleDataEntryForm.java
│ └── event/
│ └── OrderLineEventHandler.java
├── migration/
│ └── local/
│ └── 202501150930_RegisterProcessAndForm.zip
└── build.properties
The MANIFEST.MF would include all necessary imports:
Import-Package: org.compiere.model;version="0.0.0",
org.compiere.process;version="0.0.0",
org.compiere.util;version="0.0.0",
org.adempiere.base;version="0.0.0",
org.adempiere.base.event;version="0.0.0",
org.adempiere.webui.component;version="0.0.0",
org.adempiere.webui.panel;version="0.0.0",
org.osgi.framework;version="1.9.0",
org.osgi.service.event;version="1.4.0",
org.zkoss.zk.ui;version="0.0.0",
org.zkoss.zk.ui.event;version="0.0.0",
org.zkoss.zul;version="0.0.0"
Service-Component: OSGI-INF/*.xml
Key Takeaways
- Custom processes extend
SvrProcessand implement two methods:prepare()for reading parameters anddoIt()for business logic. - Use
getParameter()inprepare()to read user-provided parameters, andaddLog()indoIt()to provide progress feedback. - Return messages from
doIt()support@Error@and@OK@prefixes for status indication. - Processes must be registered in the AD (AD_Process, AD_Process_Para, AD_Menu) and resolved via
IProcessFactory. - Custom forms implement
IFormControllerand use ZK components (Textbox, Listbox, Grid, Button, Borderlayout) to build interactive interfaces. - Forms must be registered in the AD (AD_Form, AD_Menu) and resolved via
IFormFactory. - Use 2Pack to distribute the AD configuration that your processes and forms require.
What’s Next
With processes, forms, and event handlers in your toolkit, you have the core skills for iDempiere plugin development. Future lessons will explore advanced topics including custom REST API endpoints, integration with external systems, reporting with JasperReports, and performance optimization techniques for large-scale deployments.