Callouts and Field Validation

Level: Intermediate Module: General Foundation 18 min read Lesson 14 of 47

Overview

  • What you’ll learn: How to implement real-time field-level validation and dynamic behavior using iDempiere callouts, including the IColumnCallout interface, CalloutEngine patterns, and practical code examples.
  • Prerequisites: Lessons 1-12 (Beginner level), basic Java knowledge
  • Estimated reading time: 25 minutes

Introduction

In the previous lesson, you learned how to build sophisticated UIs using the Application Dictionary alone. But there are limits to what declarative configuration can do. When a user changes a field value, you often need to respond in real time: auto-fill related fields, recalculate totals, validate business rules, or show warnings. This is exactly what callouts do.

A callout is a piece of Java code that iDempiere executes immediately when a user changes a field’s value in the UI. Unlike model validators (which fire during the save process), callouts fire instantly on field change, giving the user immediate feedback. This lesson teaches you how to write, register, and debug callouts.

Understanding Callouts

Callouts sit in the UI layer. When a user modifies a field value (by typing, selecting from a dropdown, or any other input), iDempiere checks whether that column has a callout registered. If it does, the callout code executes before the user moves to the next field.

Key Characteristics

  • Triggered on field change — not on save, not on load, but on value change.
  • Synchronous — the UI waits for the callout to complete before the user can continue.
  • Can modify other fields — a callout on field A can set the values of fields B, C, and D.
  • Can return error messages — returning a non-empty string displays an error and reverts the field change.
  • UI-only — callouts do not fire during server-side imports, processes, or API calls. Only direct user interaction triggers them.

The IColumnCallout Interface

The modern way to implement callouts in iDempiere is through the IColumnCallout interface. This is the preferred approach for plugin-based development.

package org.idempiere.callout;

import java.util.Properties;
import org.adempiere.base.IColumnCallout;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;

public class MyCallout implements IColumnCallout {

    @Override
    public String start(Properties ctx, int WindowNo,
                        GridTab mTab, GridField mField,
                        Object value, Object oldValue) {

        // ctx       - Application context (preferences, session info)
        // WindowNo  - Window number (for context variable scoping)
        // mTab      - The tab containing the changed field
        // mField    - The field that was changed
        // value     - The new value
        // oldValue  - The previous value

        // Return "" for success, or an error message string to revert
        return "";
    }
}

Each parameter serves a specific purpose:

  • ctx (Properties) — The application context containing login info, preferences, and session-level variables. Access values with Env.getContext(ctx, WindowNo, "ColumnName").
  • WindowNo (int) — Identifies the window instance. Important when the user has multiple windows open, as context variables are scoped by window number.
  • mTab (GridTab) — Provides access to all fields in the current tab. Use mTab.setValue("ColumnName", newValue) to set other field values.
  • mField (GridField) — The specific field that triggered the callout. Contains metadata like column name, display type, and current value.
  • value (Object) — The new value the user entered. You must cast it to the appropriate type.
  • oldValue (Object) — The value before the change. Useful for comparison logic.

The CalloutEngine Base Class

The CalloutEngine class is the traditional (pre-plugin) approach. It is still widely used in core iDempiere code. You extend CalloutEngine and register individual methods:

package org.compiere.model;

import java.util.Properties;
import org.compiere.model.CalloutEngine;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;

public class CalloutMyTable extends CalloutEngine {

    /**
     * Called when C_BPartner_ID changes.
     * Auto-fills the contact and location fields.
     */
    public String bpartner(Properties ctx, int WindowNo,
                           GridTab mTab, GridField mField,
                           Object value) {

        if (isCalloutActive())  // Prevent recursive callouts
            return "";

        Integer bpartnerId = (Integer) value;
        if (bpartnerId == null || bpartnerId == 0)
            return "";

        // Look up the default contact for this business partner
        int contactId = DB.getSQLValue(null,
            "SELECT AD_User_ID FROM AD_User " +
            "WHERE C_BPartner_ID=? AND IsActive='Y' " +
            "ORDER BY IsDefaultContact DESC, AD_User_ID LIMIT 1",
            bpartnerId);

        if (contactId > 0) {
            mTab.setValue("AD_User_ID", contactId);
        }

        // Look up the default location
        int locationId = DB.getSQLValue(null,
            "SELECT C_BPartner_Location_ID FROM C_BPartner_Location " +
            "WHERE C_BPartner_ID=? AND IsActive='Y' " +
            "ORDER BY IsShipTo DESC, C_BPartner_Location_ID LIMIT 1",
            bpartnerId);

        if (locationId > 0) {
            mTab.setValue("C_BPartner_Location_ID", locationId);
        }

        return "";  // Success
    }
}

The isCalloutActive() Guard

Notice the isCalloutActive() check at the top. This is a critical pattern. When your callout sets a value on another field via mTab.setValue(), that field change can trigger its own callout, which might set another field, and so on — creating infinite recursion. The isCalloutActive() method returns true if a callout is already executing, preventing this chain.

Writing a Complete Callout: A Practical Example

Let us build a callout for a custom Sales Commission table. When the user selects a Product, the callout auto-fills the Commission Percentage from a rate table, and calculates the Commission Amount based on the line total.

package com.mycompany.callout;

import java.math.BigDecimal;
import java.util.Properties;
import org.adempiere.base.IColumnCallout;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;
import org.compiere.util.DB;
import org.compiere.util.Env;

public class CalloutCommission implements IColumnCallout {

    @Override
    public String start(Properties ctx, int WindowNo,
                        GridTab mTab, GridField mField,
                        Object value, Object oldValue) {

        String columnName = mField.getColumnName();

        if ("M_Product_ID".equals(columnName)) {
            return product(ctx, WindowNo, mTab, value);
        }
        if ("LineNetAmt".equals(columnName)) {
            return calculateCommission(mTab);
        }

        return "";
    }

    private String product(Properties ctx, int WindowNo,
                           GridTab mTab, Object value) {

        Integer productId = (Integer) value;
        if (productId == null || productId == 0) {
            mTab.setValue("CommissionPct", Env.ZERO);
            mTab.setValue("CommissionAmt", Env.ZERO);
            return "";
        }

        // Look up the commission rate for this product
        BigDecimal rate = DB.getSQLValueBD(null,
            "SELECT CommissionPct FROM Z_CommissionRate " +
            "WHERE M_Product_ID=? AND IsActive='Y' " +
            "AND AD_Client_ID=?",
            productId,
            Env.getAD_Client_ID(ctx));

        if (rate == null) {
            rate = Env.ZERO;
        }

        mTab.setValue("CommissionPct", rate);

        // Recalculate commission amount
        return calculateCommission(mTab);
    }

    private String calculateCommission(GridTab mTab) {
        BigDecimal lineAmt = (BigDecimal) mTab.getValue("LineNetAmt");
        BigDecimal pct = (BigDecimal) mTab.getValue("CommissionPct");

        if (lineAmt == null) lineAmt = Env.ZERO;
        if (pct == null) pct = Env.ZERO;

        // Commission = LineNetAmt * CommissionPct / 100
        BigDecimal commission = lineAmt.multiply(pct)
            .divide(Env.ONEHUNDRED, 2, BigDecimal.ROUND_HALF_UP);

        mTab.setValue("CommissionAmt", commission);
        return "";
    }
}

Registering Callouts

Method 1: Application Dictionary Registration

For CalloutEngine-style callouts, set the Callout field on the AD_Column record:

-- In AD_Column.Callout field:
org.compiere.model.CalloutMyTable.bpartner

The format is fully.qualified.ClassName.methodName. You can chain multiple callouts by separating them with semicolons:

org.compiere.model.CalloutOrder.bPartner;org.compiere.model.CalloutOrder.bPartnerBill

Method 2: IColumnCalloutFactory (Plugin Approach)

For IColumnCallout implementations, you register a factory as an OSGi service. Create a factory class:

package com.mycompany.callout;

import java.util.ArrayList;
import java.util.List;
import org.adempiere.base.IColumnCallout;
import org.adempiere.base.IColumnCalloutFactory;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;
import org.compiere.model.MColumn;

public class MyCalloutFactory implements IColumnCalloutFactory {

    @Override
    public IColumnCallout[] getColumnCallouts(String tableName,
                                               String columnName) {

        List<IColumnCallout> list = new ArrayList<>();

        if ("Z_Commission".equals(tableName)) {
            if ("M_Product_ID".equals(columnName)
                || "LineNetAmt".equals(columnName)) {
                list.add(new CalloutCommission());
            }
        }

        return list.toArray(new IColumnCallout[0]);
    }
}

Then register it in your plugin’s OSGI-INF component descriptor XML or via annotations:

<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
    name="com.mycompany.callout.factory">
    <implementation class="com.mycompany.callout.MyCalloutFactory"/>
    <service>
        <provide interface="org.adempiere.base.IColumnCalloutFactory"/>
    </service>
</scr:component>

Callout vs Model Validator: When to Use Which

This is one of the most common questions for iDempiere developers. Here is a clear decision framework:

Criteria Callout Model Validator
When it fires On field change (UI only) On save/delete (all sources)
Triggered by imports No Yes
Triggered by processes No Yes
Triggered by API/web service No Yes
User feedback Immediate (before save) At save time
Best for UX: auto-fill, calculate, hint Data integrity enforcement

Rule of thumb: Use callouts for user experience (auto-fill, calculate, show/hide). Use model validators for data integrity (must always be enforced, regardless of how data enters the system). Often you will use both: a callout for immediate feedback and a model validator as the safety net.

Common Callout Patterns

Pattern 1: Auto-Fill Related Fields

// When user selects a product, fill in UOM and price
public String product(Properties ctx, int WindowNo,
                      GridTab mTab, GridField mField, Object value) {
    Integer productId = (Integer) value;
    if (productId == null || productId == 0) return "";

    MProduct product = MProduct.get(ctx, productId);
    mTab.setValue("C_UOM_ID", product.getC_UOM_ID());
    mTab.setValue("PriceList", product.getPriceList());
    return "";
}

Pattern 2: Calculate Derived Values

// When qty or price changes, recalculate line total
public String amt(Properties ctx, int WindowNo,
                  GridTab mTab, GridField mField, Object value) {
    BigDecimal qty = (BigDecimal) mTab.getValue("QtyOrdered");
    BigDecimal price = (BigDecimal) mTab.getValue("PriceActual");

    if (qty == null) qty = Env.ZERO;
    if (price == null) price = Env.ZERO;

    mTab.setValue("LineNetAmt", qty.multiply(price));
    return "";
}

Pattern 3: Validate and Reject

// Reject negative quantities
public String qty(Properties ctx, int WindowNo,
                  GridTab mTab, GridField mField, Object value) {
    BigDecimal qty = (BigDecimal) value;
    if (qty != null && qty.signum() < 0) {
        return "Quantity cannot be negative";  // Error: reverts field
    }
    return "";
}

Debugging Callouts

When a callout does not behave as expected, use these techniques:

  • Logging: Use CLogger to add debug output: private static final CLogger log = CLogger.getCLogger(MyCallout.class); then log.fine("Callout fired for product: " + value);
  • Eclipse Debugger: Set breakpoints in your callout code and run iDempiere from Eclipse in debug mode. The debugger pauses execution when the callout fires.
  • Check Registration: Verify the callout is registered on the correct column. A common mistake is registering on the wrong table or column name.
  • Check isCalloutActive(): If your callout appears to not fire, another callout might be suppressing it via the active flag.
  • Null Checks: Always handle null values. Users can clear a field, sending null as the new value.

GridTab and GridField Access

The GridTab and GridField objects provide rich access to the current UI state:

// GridTab: Access any field in the current tab
Object val = mTab.getValue("ColumnName");           // Get current value
mTab.setValue("ColumnName", newValue);               // Set a value
int recordId = mTab.getRecord_ID();                  // Current record ID
boolean isNew = mTab.isNew();                        // Is this a new record?

// GridField: Metadata about the changed field
String colName = mField.getColumnName();             // Column name
int displayType = mField.getDisplayType();           // Display type
boolean isMandatory = mField.isMandatory(false);     // Is it mandatory?
String header = mField.getHeader();                  // Field label

Key Takeaways

  • Callouts fire on UI field change, providing immediate feedback to users.
  • The IColumnCallout interface is the modern approach; CalloutEngine is the traditional approach.
  • Always use isCalloutActive() guards in CalloutEngine to prevent infinite recursion.
  • Register callouts via AD_Column.Callout (traditional) or IColumnCalloutFactory (plugin).
  • Use callouts for UX improvements (auto-fill, calculate), use model validators for data integrity enforcement.
  • Callouts are UI-only — they do not fire during imports, processes, or API calls.
  • Return an empty string for success, or an error message to revert the field change.

What’s Next

In Lesson 15, you will learn about Workflow Management — how to configure document processing workflows, build approval chains, and automate business operations using iDempiere’s built-in workflow engine.

繁體中文

概述

  • 學習內容:如何使用 iDempiere Callout 實現即時欄位級驗證和動態行為,包括 IColumnCallout 介面、CalloutEngine 模式和實際程式碼範例。
  • 先修條件:第 1-12 課(初級),基本 Java 知識
  • 預估閱讀時間:25 分鐘

簡介

在上一課中,您學習了如何僅使用應用程式字典建構精密的使用者介面。但宣告式配置的能力有其限制。當使用者更改欄位值時,您通常需要即時回應:自動填入相關欄位、重新計算合計、驗證業務規則或顯示警告。這正是 Callout 的功能。

Callout 是一段 Java 程式碼,當使用者在 UI 中更改欄位值時,iDempiere 會立即執行它。與 Model Validator(在儲存過程中觸發)不同,Callout 在欄位更改時立即觸發,為使用者提供即時回饋。本課教您如何撰寫、註冊和除錯 Callout。

了解 Callout

Callout 位於 UI 層。當使用者修改欄位值(透過輸入、從下拉選單中選擇或任何其他輸入方式)時,iDempiere 會檢查該欄位是否已註冊 Callout。如果有,Callout 程式碼會在使用者移至下一個欄位之前執行。

主要特性

  • 在欄位更改時觸發——不是在儲存時、不是在載入時,而是在值更改時。
  • 同步執行——UI 等待 Callout 完成後使用者才能繼續操作。
  • 可修改其他欄位——欄位 A 上的 Callout 可以設定欄位 B、C 和 D 的值。
  • 可返回錯誤訊息——返回非空字串會顯示錯誤並還原欄位更改。
  • 僅限 UI——Callout 不會在伺服器端匯入、流程或 API 呼叫期間觸發。只有直接的使用者互動才會觸發它們。

IColumnCallout 介面

在 iDempiere 中實現 Callout 的現代方式是透過 IColumnCallout 介面。這是基於 Plugin 開發的首選方法。

package org.idempiere.callout;

import java.util.Properties;
import org.adempiere.base.IColumnCallout;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;

public class MyCallout implements IColumnCallout {

    @Override
    public String start(Properties ctx, int WindowNo,
                        GridTab mTab, GridField mField,
                        Object value, Object oldValue) {

        // ctx       - 應用程式上下文(偏好設定、工作階段資訊)
        // WindowNo  - 視窗編號(用於上下文變數範圍)
        // mTab      - 包含已更改欄位的頁籤
        // mField    - 已更改的欄位
        // value     - 新值
        // oldValue  - 先前的值

        // 成功返回 "",或返回錯誤訊息字串以還原
        return "";
    }
}

每個參數都有特定用途:

  • ctx(Properties)——包含登入資訊、偏好設定和工作階段級變數的應用程式上下文。使用 Env.getContext(ctx, WindowNo, "ColumnName") 存取值。
  • WindowNo(int)——識別視窗實例。當使用者開啟多個視窗時很重要,因為上下文變數以視窗編號為範圍。
  • mTab(GridTab)——提供對當前頁籤中所有欄位的存取。使用 mTab.setValue("ColumnName", newValue) 設定其他欄位值。
  • mField(GridField)——觸發 Callout 的特定欄位。包含欄位名稱、顯示類型和當前值等中繼資料。
  • value(Object)——使用者輸入的新值。您必須將其轉換為適當的類型。
  • oldValue(Object)——更改前的值。對於比較邏輯很有用。

CalloutEngine 基礎類別

CalloutEngine 類別是傳統(Plugin 之前的)方法。它仍然在 iDempiere 核心程式碼中廣泛使用。您繼承 CalloutEngine 並註冊各個方法:

package org.compiere.model;

import java.util.Properties;
import org.compiere.model.CalloutEngine;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;

public class CalloutMyTable extends CalloutEngine {

    /**
     * 當 C_BPartner_ID 更改時呼叫。
     * 自動填入聯絡人和地址欄位。
     */
    public String bpartner(Properties ctx, int WindowNo,
                           GridTab mTab, GridField mField,
                           Object value) {

        if (isCalloutActive())  // 防止遞迴 Callout
            return "";

        Integer bpartnerId = (Integer) value;
        if (bpartnerId == null || bpartnerId == 0)
            return "";

        // 查詢此業務夥伴的預設聯絡人
        int contactId = DB.getSQLValue(null,
            "SELECT AD_User_ID FROM AD_User " +
            "WHERE C_BPartner_ID=? AND IsActive='Y' " +
            "ORDER BY IsDefaultContact DESC, AD_User_ID LIMIT 1",
            bpartnerId);

        if (contactId > 0) {
            mTab.setValue("AD_User_ID", contactId);
        }

        // 查詢預設地址
        int locationId = DB.getSQLValue(null,
            "SELECT C_BPartner_Location_ID FROM C_BPartner_Location " +
            "WHERE C_BPartner_ID=? AND IsActive='Y' " +
            "ORDER BY IsShipTo DESC, C_BPartner_Location_ID LIMIT 1",
            bpartnerId);

        if (locationId > 0) {
            mTab.setValue("C_BPartner_Location_ID", locationId);
        }

        return "";  // 成功
    }
}

isCalloutActive() 防護

注意頂部的 isCalloutActive() 檢查。這是一個關鍵模式。當您的 Callout 透過 mTab.setValue() 設定另一個欄位的值時,該欄位更改可能會觸發其自身的 Callout,而後者又可能設定另一個欄位,依此類推——造成無限遞迴。isCalloutActive() 方法在 Callout 已在執行時返回 true,從而防止此鏈式反應。

撰寫完整的 Callout:實際範例

讓我們為自訂銷售佣金資料表建立一個 Callout。當使用者選擇產品時,Callout 從費率表自動填入佣金百分比,並根據明細行合計計算佣金金額。

package com.mycompany.callout;

import java.math.BigDecimal;
import java.util.Properties;
import org.adempiere.base.IColumnCallout;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;
import org.compiere.util.DB;
import org.compiere.util.Env;

public class CalloutCommission implements IColumnCallout {

    @Override
    public String start(Properties ctx, int WindowNo,
                        GridTab mTab, GridField mField,
                        Object value, Object oldValue) {

        String columnName = mField.getColumnName();

        if ("M_Product_ID".equals(columnName)) {
            return product(ctx, WindowNo, mTab, value);
        }
        if ("LineNetAmt".equals(columnName)) {
            return calculateCommission(mTab);
        }

        return "";
    }

    private String product(Properties ctx, int WindowNo,
                           GridTab mTab, Object value) {

        Integer productId = (Integer) value;
        if (productId == null || productId == 0) {
            mTab.setValue("CommissionPct", Env.ZERO);
            mTab.setValue("CommissionAmt", Env.ZERO);
            return "";
        }

        // 查詢此產品的佣金費率
        BigDecimal rate = DB.getSQLValueBD(null,
            "SELECT CommissionPct FROM Z_CommissionRate " +
            "WHERE M_Product_ID=? AND IsActive='Y' " +
            "AND AD_Client_ID=?",
            productId,
            Env.getAD_Client_ID(ctx));

        if (rate == null) {
            rate = Env.ZERO;
        }

        mTab.setValue("CommissionPct", rate);

        // 重新計算佣金金額
        return calculateCommission(mTab);
    }

    private String calculateCommission(GridTab mTab) {
        BigDecimal lineAmt = (BigDecimal) mTab.getValue("LineNetAmt");
        BigDecimal pct = (BigDecimal) mTab.getValue("CommissionPct");

        if (lineAmt == null) lineAmt = Env.ZERO;
        if (pct == null) pct = Env.ZERO;

        // 佣金 = LineNetAmt * CommissionPct / 100
        BigDecimal commission = lineAmt.multiply(pct)
            .divide(Env.ONEHUNDRED, 2, BigDecimal.ROUND_HALF_UP);

        mTab.setValue("CommissionAmt", commission);
        return "";
    }
}

註冊 Callout

方法一:應用程式字典註冊

對於 CalloutEngine 風格的 Callout,在 AD_Column 記錄上設定 Callout 欄位:

-- 在 AD_Column.Callout 欄位中:
org.compiere.model.CalloutMyTable.bpartner

格式為 完整限定類別名稱.方法名稱。您可以用分號分隔來串連多個 Callout:

org.compiere.model.CalloutOrder.bPartner;org.compiere.model.CalloutOrder.bPartnerBill

方法二:IColumnCalloutFactory(Plugin 方式)

對於 IColumnCallout 實作,您將工廠註冊為 OSGi 服務。建立一個工廠類別:

package com.mycompany.callout;

import java.util.ArrayList;
import java.util.List;
import org.adempiere.base.IColumnCallout;
import org.adempiere.base.IColumnCalloutFactory;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;
import org.compiere.model.MColumn;

public class MyCalloutFactory implements IColumnCalloutFactory {

    @Override
    public IColumnCallout[] getColumnCallouts(String tableName,
                                               String columnName) {

        List<IColumnCallout> list = new ArrayList<>();

        if ("Z_Commission".equals(tableName)) {
            if ("M_Product_ID".equals(columnName)
                || "LineNetAmt".equals(columnName)) {
                list.add(new CalloutCommission());
            }
        }

        return list.toArray(new IColumnCallout[0]);
    }
}

然後在您的 Plugin 的 OSGI-INF 元件描述器 XML 或透過註解中註冊它:

<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
    name="com.mycompany.callout.factory">
    <implementation class="com.mycompany.callout.MyCalloutFactory"/>
    <service>
        <provide interface="org.adempiere.base.IColumnCalloutFactory"/>
    </service>
</scr:component>

Callout 與 Model Validator:何時使用哪一個

這是 iDempiere 開發者最常問的問題之一。以下是清晰的決策框架:

標準 Callout Model Validator
觸發時機 欄位更改時(僅限 UI) 儲存/刪除時(所有來源)
由匯入觸發
由流程觸發
由 API/Web 服務觸發
使用者回饋 即時(儲存前) 儲存時
最適合 UX:自動填入、計算、提示 資料完整性強制執行

經驗法則:使用 Callout 改善使用者體驗(自動填入、計算、顯示/隱藏)。使用 Model Validator 強制資料完整性(無論資料如何進入系統都必須強制執行)。通常您會同時使用兩者:Callout 提供即時回饋,Model Validator 作為安全網。

常見 Callout 模式

模式一:自動填入相關欄位

// 當使用者選擇產品時,填入計量單位和價格
public String product(Properties ctx, int WindowNo,
                      GridTab mTab, GridField mField, Object value) {
    Integer productId = (Integer) value;
    if (productId == null || productId == 0) return "";

    MProduct product = MProduct.get(ctx, productId);
    mTab.setValue("C_UOM_ID", product.getC_UOM_ID());
    mTab.setValue("PriceList", product.getPriceList());
    return "";
}

模式二:計算衍生值

// 當數量或價格更改時,重新計算明細行合計
public String amt(Properties ctx, int WindowNo,
                  GridTab mTab, GridField mField, Object value) {
    BigDecimal qty = (BigDecimal) mTab.getValue("QtyOrdered");
    BigDecimal price = (BigDecimal) mTab.getValue("PriceActual");

    if (qty == null) qty = Env.ZERO;
    if (price == null) price = Env.ZERO;

    mTab.setValue("LineNetAmt", qty.multiply(price));
    return "";
}

模式三:驗證並拒絕

// 拒絕負數數量
public String qty(Properties ctx, int WindowNo,
                  GridTab mTab, GridField mField, Object value) {
    BigDecimal qty = (BigDecimal) value;
    if (qty != null && qty.signum() < 0) {
        return "數量不能為負數";  // 錯誤:還原欄位
    }
    return "";
}

Callout 除錯

當 Callout 未如預期運作時,使用以下技巧:

  • 日誌記錄:使用 CLogger 新增除錯輸出:private static final CLogger log = CLogger.getCLogger(MyCallout.class); 然後 log.fine("Callout fired for product: " + value);
  • Eclipse 除錯器:在 Callout 程式碼中設定中斷點,並在除錯模式下從 Eclipse 執行 iDempiere。除錯器會在 Callout 觸發時暫停執行。
  • 檢查註冊:驗證 Callout 是否已在正確的欄位上註冊。常見錯誤是註冊在錯誤的資料表或欄位名稱上。
  • 檢查 isCalloutActive():如果您的 Callout 似乎沒有觸發,另一個 Callout 可能透過作用中旗標抑制了它。
  • 空值檢查:始終處理空值。使用者可以清除欄位,將 null 作為新值發送。

GridTab 和 GridField 存取

GridTabGridField 物件提供對當前 UI 狀態的豐富存取:

// GridTab:存取當前頁籤中的任何欄位
Object val = mTab.getValue("ColumnName");           // 取得當前值
mTab.setValue("ColumnName", newValue);               // 設定值
int recordId = mTab.getRecord_ID();                  // 當前記錄 ID
boolean isNew = mTab.isNew();                        // 這是新記錄嗎?

// GridField:已更改欄位的中繼資料
String colName = mField.getColumnName();             // 欄位名稱
int displayType = mField.getDisplayType();           // 顯示類型
boolean isMandatory = mField.isMandatory(false);     // 是否為必填?
String header = mField.getHeader();                  // 欄位標籤

重點摘要

  • Callout 在 UI 欄位更改時觸發,為使用者提供即時回饋。
  • IColumnCallout 介面是現代方法;CalloutEngine 是傳統方法。
  • 在 CalloutEngine 中始終使用 isCalloutActive() 防護以防止無限遞迴。
  • 透過 AD_Column.Callout(傳統)或 IColumnCalloutFactory(Plugin)註冊 Callout。
  • 使用 Callout 改善使用者體驗(自動填入、計算),使用 Model Validator 強制資料完整性。
  • Callout 僅限 UI——在匯入、流程或 API 呼叫期間不會觸發。
  • 成功返回空字串,或返回錯誤訊息以還原欄位更改。

下一步

在第 15 課中,您將學習工作流程管理——如何配置文件處理工作流程、建立核准鏈,以及使用 iDempiere 內建的工作流程引擎自動化業務營運。

日本語

概要

  • 学習内容:IColumnCalloutインターフェース、CalloutEngineパターン、実践的なコード例を含む、iDempiereコールアウトを使用したリアルタイムのフィールドレベルバリデーションと動的動作の実装方法。
  • 前提条件:レッスン1〜12(初級レベル)、基本的なJava知識
  • 推定読了時間:25分

はじめに

前のレッスンでは、アプリケーション辞書のみを使用して高度なUIを構築する方法を学びました。しかし、宣言的な設定でできることには限界があります。ユーザーがフィールド値を変更したとき、リアルタイムで応答する必要がよくあります:関連フィールドの自動入力、合計の再計算、ビジネスルールの検証、警告の表示などです。これがまさにコールアウトの役割です。

コールアウトは、ユーザーがUIでフィールドの値を変更したときにiDempiereが即座に実行するJavaコードです。Model Validator(保存プロセス中に発火する)とは異なり、コールアウトはフィールド変更時に即座に発火し、ユーザーに即時のフィードバックを提供します。このレッスンでは、コールアウトの作成、登録、デバッグ方法を学びます。

コールアウトの理解

コールアウトはUIレイヤーに位置します。ユーザーがフィールド値を変更すると(入力、ドロップダウンからの選択、その他の入力方法で)、iDempiereはそのカラムにコールアウトが登録されているかどうかをチェックします。登録されている場合、ユーザーが次のフィールドに移動する前にコールアウトコードが実行されます。

主な特性

  • フィールド変更時にトリガー——保存時でもロード時でもなく、値の変更時。
  • 同期実行——UIはコールアウトが完了するまでユーザーの操作を待機します。
  • 他のフィールドを変更可能——フィールドAのコールアウトがフィールドB、C、Dの値を設定できます。
  • エラーメッセージを返せる——空でない文字列を返すとエラーが表示され、フィールドの変更が元に戻されます。
  • UIのみ——コールアウトはサーバーサイドのインポート、プロセス、API呼び出し中には発火しません。直接のユーザー操作のみがトリガーとなります。

IColumnCalloutインターフェース

iDempiereでコールアウトを実装する現代的な方法は、IColumnCalloutインターフェースを通じて行います。これはプラグインベースの開発で推奨されるアプローチです。

package org.idempiere.callout;

import java.util.Properties;
import org.adempiere.base.IColumnCallout;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;

public class MyCallout implements IColumnCallout {

    @Override
    public String start(Properties ctx, int WindowNo,
                        GridTab mTab, GridField mField,
                        Object value, Object oldValue) {

        // ctx       - アプリケーションコンテキスト(設定、セッション情報)
        // WindowNo  - ウィンドウ番号(コンテキスト変数のスコープ用)
        // mTab      - 変更されたフィールドを含むタブ
        // mField    - 変更されたフィールド
        // value     - 新しい値
        // oldValue  - 以前の値

        // 成功時は ""を返し、エラーメッセージ文字列を返すと元に戻す
        return "";
    }
}

各パラメータには特定の目的があります:

  • ctx(Properties)——ログイン情報、設定、セッションレベル変数を含むアプリケーションコンテキスト。Env.getContext(ctx, WindowNo, "ColumnName")で値にアクセスします。
  • WindowNo(int)——ウィンドウインスタンスを識別します。ユーザーが複数のウィンドウを開いている場合に重要で、コンテキスト変数はウィンドウ番号でスコープされます。
  • mTab(GridTab)——現在のタブ内のすべてのフィールドへのアクセスを提供します。mTab.setValue("ColumnName", newValue)で他のフィールド値を設定します。
  • mField(GridField)——コールアウトをトリガーした特定のフィールド。カラム名、表示タイプ、現在の値などのメタデータを含みます。
  • value(Object)——ユーザーが入力した新しい値。適切な型にキャストする必要があります。
  • oldValue(Object)——変更前の値。比較ロジックに便利です。

CalloutEngine基底クラス

CalloutEngineクラスは従来の(プラグイン以前の)アプローチです。iDempiereのコアコードで今でも広く使用されています。CalloutEngineを継承し、個々のメソッドを登録します:

package org.compiere.model;

import java.util.Properties;
import org.compiere.model.CalloutEngine;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;

public class CalloutMyTable extends CalloutEngine {

    /**
     * C_BPartner_IDが変更されたときに呼び出されます。
     * 連絡先と住所フィールドを自動入力します。
     */
    public String bpartner(Properties ctx, int WindowNo,
                           GridTab mTab, GridField mField,
                           Object value) {

        if (isCalloutActive())  // 再帰的コールアウトを防止
            return "";

        Integer bpartnerId = (Integer) value;
        if (bpartnerId == null || bpartnerId == 0)
            return "";

        // この取引先のデフォルト連絡先を検索
        int contactId = DB.getSQLValue(null,
            "SELECT AD_User_ID FROM AD_User " +
            "WHERE C_BPartner_ID=? AND IsActive='Y' " +
            "ORDER BY IsDefaultContact DESC, AD_User_ID LIMIT 1",
            bpartnerId);

        if (contactId > 0) {
            mTab.setValue("AD_User_ID", contactId);
        }

        // デフォルト住所を検索
        int locationId = DB.getSQLValue(null,
            "SELECT C_BPartner_Location_ID FROM C_BPartner_Location " +
            "WHERE C_BPartner_ID=? AND IsActive='Y' " +
            "ORDER BY IsShipTo DESC, C_BPartner_Location_ID LIMIT 1",
            bpartnerId);

        if (locationId > 0) {
            mTab.setValue("C_BPartner_Location_ID", locationId);
        }

        return "";  // 成功
    }
}

isCalloutActive()ガード

先頭のisCalloutActive()チェックに注目してください。これは重要なパターンです。コールアウトがmTab.setValue()で別のフィールドに値を設定すると、そのフィールドの変更がそれ自身のコールアウトをトリガーし、さらに別のフィールドを設定する可能性があります——無限再帰を引き起こします。isCalloutActive()メソッドは、コールアウトが既に実行中の場合にtrueを返し、この連鎖を防ぎます。

完全なコールアウトの作成:実践例

カスタム販売手数料テーブル用のコールアウトを構築しましょう。ユーザーが製品を選択すると、コールアウトはレートテーブルから手数料率を自動入力し、明細行合計に基づいて手数料金額を計算します。

package com.mycompany.callout;

import java.math.BigDecimal;
import java.util.Properties;
import org.adempiere.base.IColumnCallout;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;
import org.compiere.util.DB;
import org.compiere.util.Env;

public class CalloutCommission implements IColumnCallout {

    @Override
    public String start(Properties ctx, int WindowNo,
                        GridTab mTab, GridField mField,
                        Object value, Object oldValue) {

        String columnName = mField.getColumnName();

        if ("M_Product_ID".equals(columnName)) {
            return product(ctx, WindowNo, mTab, value);
        }
        if ("LineNetAmt".equals(columnName)) {
            return calculateCommission(mTab);
        }

        return "";
    }

    private String product(Properties ctx, int WindowNo,
                           GridTab mTab, Object value) {

        Integer productId = (Integer) value;
        if (productId == null || productId == 0) {
            mTab.setValue("CommissionPct", Env.ZERO);
            mTab.setValue("CommissionAmt", Env.ZERO);
            return "";
        }

        // この製品の手数料率を検索
        BigDecimal rate = DB.getSQLValueBD(null,
            "SELECT CommissionPct FROM Z_CommissionRate " +
            "WHERE M_Product_ID=? AND IsActive='Y' " +
            "AND AD_Client_ID=?",
            productId,
            Env.getAD_Client_ID(ctx));

        if (rate == null) {
            rate = Env.ZERO;
        }

        mTab.setValue("CommissionPct", rate);

        // 手数料金額を再計算
        return calculateCommission(mTab);
    }

    private String calculateCommission(GridTab mTab) {
        BigDecimal lineAmt = (BigDecimal) mTab.getValue("LineNetAmt");
        BigDecimal pct = (BigDecimal) mTab.getValue("CommissionPct");

        if (lineAmt == null) lineAmt = Env.ZERO;
        if (pct == null) pct = Env.ZERO;

        // 手数料 = LineNetAmt * CommissionPct / 100
        BigDecimal commission = lineAmt.multiply(pct)
            .divide(Env.ONEHUNDRED, 2, BigDecimal.ROUND_HALF_UP);

        mTab.setValue("CommissionAmt", commission);
        return "";
    }
}

コールアウトの登録

方法1:アプリケーション辞書での登録

CalloutEngineスタイルのコールアウトの場合、AD_ColumnレコードのCalloutフィールドを設定します:

-- AD_Column.Calloutフィールドに:
org.compiere.model.CalloutMyTable.bpartner

形式は完全修飾クラス名.メソッド名です。セミコロンで区切って複数のコールアウトを連鎖させることができます:

org.compiere.model.CalloutOrder.bPartner;org.compiere.model.CalloutOrder.bPartnerBill

方法2:IColumnCalloutFactory(プラグインアプローチ)

IColumnCallout実装の場合、ファクトリーをOSGiサービスとして登録します。ファクトリークラスを作成します:

package com.mycompany.callout;

import java.util.ArrayList;
import java.util.List;
import org.adempiere.base.IColumnCallout;
import org.adempiere.base.IColumnCalloutFactory;
import org.compiere.model.GridField;
import org.compiere.model.GridTab;
import org.compiere.model.MColumn;

public class MyCalloutFactory implements IColumnCalloutFactory {

    @Override
    public IColumnCallout[] getColumnCallouts(String tableName,
                                               String columnName) {

        List<IColumnCallout> list = new ArrayList<>();

        if ("Z_Commission".equals(tableName)) {
            if ("M_Product_ID".equals(columnName)
                || "LineNetAmt".equals(columnName)) {
                list.add(new CalloutCommission());
            }
        }

        return list.toArray(new IColumnCallout[0]);
    }
}

次に、プラグインのOSGI-INFコンポーネントディスクリプタXMLまたはアノテーションで登録します:

<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
    name="com.mycompany.callout.factory">
    <implementation class="com.mycompany.callout.MyCalloutFactory"/>
    <service>
        <provide interface="org.adempiere.base.IColumnCalloutFactory"/>
    </service>
</scr:component>

コールアウト vs Model Validator:使い分け

これはiDempiere開発者にとって最も一般的な質問の1つです。以下が明確な判断フレームワークです:

基準 コールアウト Model Validator
発火タイミング フィールド変更時(UIのみ) 保存/削除時(全ソース)
インポートで発火 いいえ はい
プロセスで発火 いいえ はい
API/Webサービスで発火 いいえ はい
ユーザーフィードバック 即時(保存前) 保存時
最適な用途 UX:自動入力、計算、ヒント データ整合性の強制

経験則:ユーザーエクスペリエンスの向上(自動入力、計算、表示/非表示)にはコールアウトを使用します。データ整合性の強制(データがシステムにどのように入るかに関係なく常に強制される必要がある)にはModel Validatorを使用します。多くの場合、両方を使用します:即時フィードバック用のコールアウトと安全ネットとしてのModel Validator。

一般的なコールアウトパターン

パターン1:関連フィールドの自動入力

// ユーザーが製品を選択したら、UOMと価格を入力
public String product(Properties ctx, int WindowNo,
                      GridTab mTab, GridField mField, Object value) {
    Integer productId = (Integer) value;
    if (productId == null || productId == 0) return "";

    MProduct product = MProduct.get(ctx, productId);
    mTab.setValue("C_UOM_ID", product.getC_UOM_ID());
    mTab.setValue("PriceList", product.getPriceList());
    return "";
}

パターン2:派生値の計算

// 数量または価格が変更されたら、明細行合計を再計算
public String amt(Properties ctx, int WindowNo,
                  GridTab mTab, GridField mField, Object value) {
    BigDecimal qty = (BigDecimal) mTab.getValue("QtyOrdered");
    BigDecimal price = (BigDecimal) mTab.getValue("PriceActual");

    if (qty == null) qty = Env.ZERO;
    if (price == null) price = Env.ZERO;

    mTab.setValue("LineNetAmt", qty.multiply(price));
    return "";
}

パターン3:検証と拒否

// 負の数量を拒否
public String qty(Properties ctx, int WindowNo,
                  GridTab mTab, GridField mField, Object value) {
    BigDecimal qty = (BigDecimal) value;
    if (qty != null && qty.signum() < 0) {
        return "数量は負の値にできません";  // エラー:フィールドを元に戻す
    }
    return "";
}

コールアウトのデバッグ

コールアウトが期待通りに動作しない場合、以下のテクニックを使用します:

  • ログ記録:CLoggerを使用してデバッグ出力を追加:private static final CLogger log = CLogger.getCLogger(MyCallout.class);、次にlog.fine("Callout fired for product: " + value);
  • Eclipseデバッガ:コールアウトコードにブレークポイントを設定し、デバッグモードでEclipseからiDempiereを実行します。コールアウトが発火するとデバッガが実行を一時停止します。
  • 登録の確認:コールアウトが正しいカラムに登録されていることを確認します。よくある間違いは、誤ったテーブルやカラム名に登録することです。
  • isCalloutActive()の確認:コールアウトが発火しないように見える場合、別のコールアウトがアクティブフラグを通じて抑制している可能性があります。
  • Nullチェック:常にnull値を処理してください。ユーザーはフィールドをクリアでき、新しい値としてnullが送信されます。

GridTabとGridFieldのアクセス

GridTabGridFieldオブジェクトは、現在のUI状態への豊富なアクセスを提供します:

// GridTab:現在のタブ内の任意のフィールドにアクセス
Object val = mTab.getValue("ColumnName");           // 現在の値を取得
mTab.setValue("ColumnName", newValue);               // 値を設定
int recordId = mTab.getRecord_ID();                  // 現在のレコードID
boolean isNew = mTab.isNew();                        // 新しいレコードか?

// GridField:変更されたフィールドのメタデータ
String colName = mField.getColumnName();             // カラム名
int displayType = mField.getDisplayType();           // 表示タイプ
boolean isMandatory = mField.isMandatory(false);     // 必須か?
String header = mField.getHeader();                  // フィールドラベル

重要ポイント

  • コールアウトはUIフィールドの変更時に発火し、ユーザーに即時フィードバックを提供します。
  • IColumnCalloutインターフェースは現代的なアプローチ、CalloutEngineは従来のアプローチです。
  • CalloutEngineでは常にisCalloutActive()ガードを使用して無限再帰を防止してください。
  • AD_Column.Callout(従来)またはIColumnCalloutFactory(プラグイン)でコールアウトを登録します。
  • UXの改善(自動入力、計算)にはコールアウトを、データ整合性の強制にはModel Validatorを使用します。
  • コールアウトはUIのみ——インポート、プロセス、API呼び出し中には発火しません。
  • 成功時は空文字列を返し、フィールドの変更を元に戻す場合はエラーメッセージを返します。

次のステップ

レッスン15では、ワークフロー管理について学びます——ドキュメント処理ワークフローの設定方法、承認チェーンの構築方法、iDempiereの組み込みワークフローエンジンを使用したビジネスオペレーションの自動化方法です。

You Missed