iDempiere

iDempiere Kanban 踩坑三語實錄:主管才能改優先度,postEvent 救了全世界

2026-04-17 · 12 分鐘 · Ray Lee (System Analyst)

Part 1:優先度是主管的專屬玩具

需求很簡單:「優先度」欄位只有申請人(AD_User_ID)的直屬主管能改,負責人(SalesRep)和申請人(Requestor)只能眼巴巴看著。事情急不急,不是你說了算,是你上司說了算。

欄位主管負責人 / 申請人
優先度✅ 可改❌ 看著就好
負責人✅ 可改✅ 可改
開始/結束時間✅ 可改✅ 可改
處理結果✅ 可改✅ 可改
儲存按鈕✅ 可按✅ 可按

實作:RequestKanbanVM.java 加入 isSupervisorOf()

原理很簡單:查一條 SQL,看你是不是申請人的直屬主管。不需要遞迴爬組織圖,一步到位:

public boolean isSupervisorOf(int adUserId) {
    int myId = Env.getAD_User_ID(Env.getCtx());
    int supervisorId = DB.getSQLValue(null,
        "SELECT supervisor_id FROM ad_user WHERE ad_user_id = ?", adUserId);
    return supervisorId == myId;
}

然後在 RequestKanbanForm.javasetupUpdateDialog() 裡,依身份決定誰能動什麼:

boolean canEdit      = vm.canEditRequest(request);   // 負責人或申請人
boolean isSupervisor = vm.isSupervisorOf(request.getAD_User_ID());
boolean canEditAny   = canEdit || isSupervisor;

// 優先度——只有主管能改
fUpdatePriority.setReadWrite(isSupervisor);

// 其他欄位——主管或有編輯權者皆可
fUpdateSalesRep.setReadWrite(canEditAny);
fStartTime.setDisabled(!canEditAny);
fEndTime.setDisabled(!canEditAny);
fUpdateResult.setReadonly(!canEditAny);
btnSave.setDisabled(!canEditAny);

重點:canEditRequest()isSupervisorOf() 是獨立的。如果你同時是主管又是負責人,canEditAny = true 加上 isSupervisor = true,什麼都能改。人生勝利組。


Part 2:讓所有瀏覽器同步刷新——三次失敗,一次頓悟

問題

A 存了一張單,B 的看板還在顯示舊資料。OSGi EventHandler 都掛好了,為什麼就是不動?

失敗一:this.getDesktop() 在 handler 裡回 null

// 錯誤示範:從 OSGi EventAdmin thread 呼叫 getDesktop() 可能回 null
@Override
public void handleEvent(org.osgi.service.event.Event event) {
    Desktop desktop = this.getDesktop();  // 人不在座位
    if (desktop == null || !desktop.isAlive()) return;
    Executions.schedule(desktop, e -> vm.refreshCurrentView(), ...);
}

getDesktop() 只在 ZK request thread 上有意義。在 OSGi thread 裡呼叫它,就像打電話給已經下班的人——沒人接。

失敗二:topic 寫錯頻道

以為 iDempiere 的 model event topic 長這樣:

org/compiere/model/R_Request/*

其實根本不存在。正確的 topic 在 IEventTopics 裡:

PO_AFTER_NEW    = "adempiere/po/afterNew"
PO_AFTER_CHANGE = "adempiere/po/afterChange"
PO_AFTER_DELETE = "adempiere/po/afterDelete"

對著一個不存在的 topic 廣播,就像在沒人的頻道裡直播——技術上沒有錯,但完全沒有用。

失敗三:sendEvent() 是同步的(最深的坑)

即使 topic 對了、desktop 也抓對了,還是不動。原因在這裡:

ModelValidationEngine.fireModelChange()
  → EventManager.getInstance().sendEvent(event)   ← 同步!
    → subscriber.handleEvent() 跑在瀏覽器 A 的 ZK request thread 上
      → Executions.schedule(myDesktop_B, ...)
        → 瀏覽器 B 的 server push 沒被正確觸發

sendEvent() 是同步的(OSGi EventAdmin 規格就這樣定的)。handler 跑在瀏覽器 A 的 ZK thread 上。從一個正在服務 A 的 thread,去叫 ZK 用 server push 通知 B,就像你正在開會,叫助理「順便」去幫另一個部門開會——助理:「我現在不在那個會議室。」

解法:postEvent(),一個字的差距,數小時的代價

改用 postEvent(),事件丟進 OSGi 的異步佇列,由另一條 thread 執行 handler。從那條 thread,Executions.schedule() 才能正確喚醒其他瀏覽器:

request.save()
  → vm.broadcastRefresh()
    → EventManager.getInstance().postEvent(...)   ← 異步,立即返回
      → [OSGi EventAdmin thread]
        → subscriber.handleEvent()
          → Executions.schedule(myDesktop_B, ...)  ← 從非 ZK thread 呼叫 ✅
            → 瀏覽器 B server push 喚醒 ✅
              → vm.refreshCurrentView() ✅

完整實作

Step 1:自訂 topic(在 RequestKanbanVM

public static final String TOPIC_KANBAN_REFRESH = "kanbanform/request/refresh";

Step 2:broadcastRefresh()——每次存檔後廣播

public void broadcastRefresh() {
    EventManager.getInstance().postEvent(
        new org.osgi.service.event.Event(TOPIC_KANBAN_REFRESH, new java.util.HashMap<>()));
}

三個存檔點都要呼叫:

// 拖曳改狀態
if (!req.save()) { ... return; }
broadcastRefresh();
refreshKanbanData();
BindUtils.postNotifyChange(this, "*");

// 新增申請單
if (!req.save()) { ... return; }
broadcastRefresh();
refreshKanbanData();
BindUtils.postNotifyChange(this, "*");

// 更新對話框存檔
if (anyChange) {
    request.save();
    vm.broadcastRefresh();
}
dialog.detach();
vm.refreshCurrentView();

Step 3:初始化時搶先抓住 desktop + 開啟 server push

myDesktop = Executions.getCurrent().getDesktop();
if (!myDesktop.isServerPushEnabled()) myDesktop.enableServerPush(true);

@Override
public void onPageAttached(Page newpage, Page oldpage) {
    super.onPageAttached(newpage, oldpage);
    if (newpage != null && !newpage.getDesktop().isServerPushEnabled())
        newpage.getDesktop().enableServerPush(true);
}

為什麼要在 init 時抓?因為 Executions.getCurrent() 只在 ZK request thread 有效。先存起來,之後在 OSGi thread 裡就永遠拿得到。就像出門前先查好地址,不要到了才說找不到路。

Step 4:註冊 subscriber

private void registerOsgiEventHandler() {
    eventSubscriber = osgiEvent -> {
        if (myDesktop == null || !myDesktop.isAlive()) return;
        try {
            Executions.schedule(myDesktop,
                e -> vm.refreshCurrentView(),
                new Event("onServerPushRefresh"));
        } catch (Exception ex) {
            log.log(Level.WARNING, "Kanban refresh schedule failed", ex);
        }
    };
    EventManager.getInstance().register(
        RequestKanbanVM.TOPIC_KANBAN_REFRESH, eventSubscriber);
}

Step 5:頁面關閉時 unregister(這步不做,記憶體會哭泣)

@Override
public void onPageDetached(Page page) {
    if (eventSubscriber != null) {
        EventManager.getInstance().unregister(eventSubscriber);
        eventSubscriber = null;
    }
    super.onPageDetached(page);
}

不 unregister,subscriber 會繼續活著,對著一個已關閉的分頁不斷嘗試刷新。就像離職員工的帳號忘了停用——記憶體一點一點流走,直到有人發現伺服器變慢為止。

Step 6:refreshCurrentView()——三種 view 全部顧到

public void refreshCurrentView() {
    if (VIEW_GANTT.equals(currentView)) {
        refreshGanttHtml();
        refreshProjectPanel();
    } else {
        refreshKanbanData();
        BindUtils.postNotifyChange(this, "*");
    }
}

三個關鍵原則

原則錯誤做法正確做法
Desktop 引用handler 裡呼叫 this.getDesktop()初始化時用 Executions.getCurrent().getDesktop() 存起來
事件派發sendEvent() 同步(跑在 ZK thread)postEvent() 異步(跑在 OSGi thread)
訂閱管理topic 寫錯、忘了 unregisterEventManager.register() 自訂 topic,頁面關閉時清掉
English

Part 1: Priority Is the Boss’s Exclusive Toy

The requirement is straightforward: only the direct supervisor of the requestor (AD_User_ID) can edit the “Priority” field. The person in charge (SalesRep) and the requestor can see it just fine — they just can’t touch it. Whether something is urgent is not their call. It’s the boss’s call. Always.

FieldSupervisorSalesRep / Requestor
Priority✅ editable❌ look but don’t touch
SalesRep✅ editable✅ editable
Start / End Time✅ editable✅ editable
Update Result✅ editable✅ editable
Save button✅ enabled✅ enabled

Implementation: isSupervisorOf() in RequestKanbanVM.java

One SQL query. No recursive org-chart traversal. Just check if the current user is the direct supervisor of the requestor:

public boolean isSupervisorOf(int adUserId) {
    int myId = Env.getAD_User_ID(Env.getCtx());
    int supervisorId = DB.getSQLValue(null,
        "SELECT supervisor_id FROM ad_user WHERE ad_user_id = ?", adUserId);
    return supervisorId == myId;
}

Then in setupUpdateDialog() in RequestKanbanForm.java, wire up the permissions:

boolean canEdit      = vm.canEditRequest(request);   // SalesRep or Requestor
boolean isSupervisor = vm.isSupervisorOf(request.getAD_User_ID());
boolean canEditAny   = canEdit || isSupervisor;

// Priority — supervisor only
fUpdatePriority.setReadWrite(isSupervisor);

// Everything else — supervisor or anyone with edit rights
fUpdateSalesRep.setReadWrite(canEditAny);
fStartTime.setDisabled(!canEditAny);
fEndTime.setDisabled(!canEditAny);
fUpdateResult.setReadonly(!canEditAny);
btnSave.setDisabled(!canEditAny);

Key point: canEditRequest() and isSupervisorOf() are independent. If you happen to be both the supervisor and the SalesRep, canEditAny = true and isSupervisor = true — you can edit everything. Lucky you.


Part 2: Real-Time Cross-Browser Refresh — Three Failures, One Revelation

The Problem

User A saves a request. User B’s Kanban board keeps showing stale data. The OSGi EventHandler is wired up. So why isn’t it working?

Failure #1: this.getDesktop() returns null inside the handler

// WRONG: getDesktop() called from an OSGi EventAdmin thread may return null
@Override
public void handleEvent(org.osgi.service.event.Event event) {
    Desktop desktop = this.getDesktop();  // nobody's home
    if (desktop == null || !desktop.isAlive()) return;
    Executions.schedule(desktop, e -> vm.refreshCurrentView(), ...);
}

getDesktop() only has meaning on a ZK request thread. Calling it from an OSGi thread is like phoning someone who has already left the office. It just returns null and moves on.

Failure #2: Wrong event topic

We assumed iDempiere’s model event topic looked like this:

org/compiere/model/R_Request/*

It doesn’t exist. The correct topics are defined in IEventTopics:

PO_AFTER_NEW    = "adempiere/po/afterNew"
PO_AFTER_CHANGE = "adempiere/po/afterChange"
PO_AFTER_DELETE = "adempiere/po/afterDelete"

Broadcasting to a nonexistent topic is technically valid Java. It is completely useless.

Failure #3: sendEvent() is synchronous (the real trap)

ModelValidationEngine.fireModelChange()
  → EventManager.getInstance().sendEvent(event)   ← SYNCHRONOUS
    → subscriber.handleEvent() runs on Browser A's ZK request thread
      → Executions.schedule(myDesktop_B, ...)
        → Browser B's server push NOT correctly triggered

sendEvent() is synchronous per the OSGi EventAdmin spec. The handler runs on Browser A’s ZK thread. Calling Executions.schedule() for Browser B’s desktop from within Browser A’s active ZK execution does not correctly trigger Browser B’s server push. Like trying to call a friend while you’re already on a call with them — it just doesn’t connect.

The Fix: postEvent() — one word, hours of debugging

request.save()
  → vm.broadcastRefresh()
    → EventManager.getInstance().postEvent(...)   ← ASYNC, returns immediately
      → [OSGi EventAdmin thread]
        → subscriber.handleEvent()
          → Executions.schedule(myDesktop_B, ...)  ← called from non-ZK thread ✅
            → Browser B server push wake-up ✅
              → vm.refreshCurrentView() ✅

Full Implementation

Step 1: Custom topic constant

public static final String TOPIC_KANBAN_REFRESH = "kanbanform/request/refresh";

Step 2: broadcastRefresh() — fire after every save

public void broadcastRefresh() {
    EventManager.getInstance().postEvent(
        new org.osgi.service.event.Event(TOPIC_KANBAN_REFRESH, new java.util.HashMap<>()));
}

Called at all three save points:

// Drag-drop status change
if (!req.save()) { ... return; }
broadcastRefresh();
refreshKanbanData();
BindUtils.postNotifyChange(this, "*");

// New request dialog
if (!req.save()) { ... return; }
broadcastRefresh();
refreshKanbanData();
BindUtils.postNotifyChange(this, "*");

// Update dialog save
if (anyChange) {
    request.save();
    vm.broadcastRefresh();
}
dialog.detach();
vm.refreshCurrentView();

Step 3: Capture desktop at init + enable server push

myDesktop = Executions.getCurrent().getDesktop();
if (!myDesktop.isServerPushEnabled()) myDesktop.enableServerPush(true);

@Override
public void onPageAttached(Page newpage, Page oldpage) {
    super.onPageAttached(newpage, oldpage);
    if (newpage != null && !newpage.getDesktop().isServerPushEnabled())
        newpage.getDesktop().enableServerPush(true);
}

Why capture at init? Executions.getCurrent() is only available on a ZK request thread. Store it then, and it’s always available — never null — inside the OSGi handler. Write down the address before you leave the house, not after you’re already lost.

Step 4: Register the subscriber

private void registerOsgiEventHandler() {
    eventSubscriber = osgiEvent -> {
        if (myDesktop == null || !myDesktop.isAlive()) return;
        try {
            Executions.schedule(myDesktop,
                e -> vm.refreshCurrentView(),
                new Event("onServerPushRefresh"));
        } catch (Exception ex) {
            log.log(Level.WARNING, "Kanban refresh schedule failed", ex);
        }
    };
    EventManager.getInstance().register(
        RequestKanbanVM.TOPIC_KANBAN_REFRESH, eventSubscriber);
}

Step 5: Unregister on detach (skip this and your server cries)

@Override
public void onPageDetached(Page page) {
    if (eventSubscriber != null) {
        EventManager.getInstance().unregister(eventSubscriber);
        eventSubscriber = null;
    }
    super.onPageDetached(page);
}

Without this, the subscriber keeps running after the tab is closed — the software equivalent of an ex who still has your Netflix password. Consuming resources, serving no purpose, slowly draining memory until someone notices the server is sluggish.

Step 6: refreshCurrentView() — handles all three views

public void refreshCurrentView() {
    if (VIEW_GANTT.equals(currentView)) {
        refreshGanttHtml();
        refreshProjectPanel();
    } else {
        refreshKanbanData();
        BindUtils.postNotifyChange(this, "*");
    }
}

The Three Rules

RuleWrongCorrect
Desktop referencethis.getDesktop() inside handlerCapture Executions.getCurrent().getDesktop() at init
Event deliverysendEvent() synchronous (ZK thread)postEvent() async (OSGi thread)
SubscriptionWrong topic, missing unregisterEventManager.register() custom topic, clean up on detach
日本語

Part 1:優先度は上司だけが触れる神聖なフィールド

要件はシンプル:「優先度」フィールドを編集できるのは、申請者(AD_User_ID)の直属の上司だけ。担当者(SalesRep)も申請者(Requestor)も、見ることはできる。ただし触ることはできない。何が急ぎかを決めるのは現場ではなく、上司である。どの会社でも。

フィールド上司担当者 / 申請者
優先度✅ 編集可❌ 見るだけ
担当者✅ 編集可✅ 編集可
開始 / 終了時間✅ 編集可✅ 編集可
処理結果✅ 編集可✅ 編集可
保存ボタン✅ 押せる✅ 押せる

実装:RequestKanbanVM.javaisSupervisorOf() を追加

SQL 一発。組織図の再帰探索は不要。現在のユーザーが申請者の直属の上司かどうかを確認するだけ:

public boolean isSupervisorOf(int adUserId) {
    int myId = Env.getAD_User_ID(Env.getCtx());
    int supervisorId = DB.getSQLValue(null,
        "SELECT supervisor_id FROM ad_user WHERE ad_user_id = ?", adUserId);
    return supervisorId == myId;
}

次に RequestKanbanForm.javasetupUpdateDialog() で権限を制御:

boolean canEdit      = vm.canEditRequest(request);   // 担当者または申請者
boolean isSupervisor = vm.isSupervisorOf(request.getAD_User_ID());
boolean canEditAny   = canEdit || isSupervisor;

// 優先度——上司のみ編集可
fUpdatePriority.setReadWrite(isSupervisor);

// その他——上司または編集権限のあるユーザー
fUpdateSalesRep.setReadWrite(canEditAny);
fStartTime.setDisabled(!canEditAny);
fEndTime.setDisabled(!canEditAny);
fUpdateResult.setReadonly(!canEditAny);
btnSave.setDisabled(!canEditAny);

ポイント:canEditRequest()isSupervisorOf() は独立している。上司かつ担当者でもある場合、canEditAny = true かつ isSupervisor = true — すべて編集できる。人生の勝ち組。


Part 2:全ブラウザのリアルタイム更新——三回の失敗、一回の悟り

問題

ユーザーAが申請を保存した。ユーザーBのカンバン画面は古いデータのまま。OSGi の EventHandler はちゃんと登録している。なぜ動かない?

失敗その1:this.getDesktop() が null を返す

// 間違い:OSGi スレッドから getDesktop() を呼ぶと null になる場合がある
@Override
public void handleEvent(org.osgi.service.event.Event event) {
    Desktop desktop = this.getDesktop();  // 席を外している
    if (desktop == null || !desktop.isAlive()) return;
    Executions.schedule(desktop, e -> vm.refreshCurrentView(), ...);
}

getDesktop() は ZK リクエストスレッド上でのみ意味を持つ。OSGi スレッドから呼ぶのは、すでに退勤した人に電話するようなもの——null が返ってくるだけ。

失敗その2:存在しないトピックに向かって叫ぶ

org/compiere/model/R_Request/*  ← 存在しない

正しいトピックは IEventTopics に定義されている:

PO_AFTER_NEW    = "adempiere/po/afterNew"
PO_AFTER_CHANGE = "adempiere/po/afterChange"
PO_AFTER_DELETE = "adempiere/po/afterDelete"

失敗その3:sendEvent() は同期処理(本命の罠)

ModelValidationEngine.fireModelChange()
  → EventManager.getInstance().sendEvent(event)   ← 同期!
    → subscriber.handleEvent() がブラウザAの ZK スレッドで実行
      → Executions.schedule(myDesktop_B, ...)
        → ブラウザBの server push が正しくトリガーされない

sendEvent() は OSGi 仕様により同期処理。ブラウザAのスレッドからブラウザBを起こすことはできない。

解決策:postEvent()——一単語の違い、数時間のデバッグ

request.save()
  → vm.broadcastRefresh()
    → EventManager.getInstance().postEvent(...)   ← 非同期
      → [OSGi EventAdmin スレッド]
        → subscriber.handleEvent()
          → Executions.schedule(myDesktop_B, ...)  ← 非 ZK スレッドから ✅
            → ブラウザB server push 起動 ✅
              → vm.refreshCurrentView() ✅

完全な実装

Step 1:カスタムトピック定数

public static final String TOPIC_KANBAN_REFRESH = "kanbanform/request/refresh";

Step 2:broadcastRefresh()

public void broadcastRefresh() {
    EventManager.getInstance().postEvent(
        new org.osgi.service.event.Event(TOPIC_KANBAN_REFRESH, new java.util.HashMap<>()));
}

3つの保存ポイントすべてで呼び出す:

// ドラッグ&ドロップ
if (!req.save()) { ... return; }
broadcastRefresh();
refreshKanbanData();
BindUtils.postNotifyChange(this, "*");

// 新規申請
if (!req.save()) { ... return; }
broadcastRefresh();
refreshKanbanData();
BindUtils.postNotifyChange(this, "*");

// 更新ダイアログ
if (anyChange) {
    request.save();
    vm.broadcastRefresh();
}
dialog.detach();
vm.refreshCurrentView();

Step 3:初期化時にデスクトップを取得

myDesktop = Executions.getCurrent().getDesktop();
if (!myDesktop.isServerPushEnabled()) myDesktop.enableServerPush(true);

@Override
public void onPageAttached(Page newpage, Page oldpage) {
    super.onPageAttached(newpage, oldpage);
    if (newpage != null && !newpage.getDesktop().isServerPushEnabled())
        newpage.getDesktop().enableServerPush(true);
}

Step 4:サブスクライバー登録

private void registerOsgiEventHandler() {
    eventSubscriber = osgiEvent -> {
        if (myDesktop == null || !myDesktop.isAlive()) return;
        try {
            Executions.schedule(myDesktop,
                e -> vm.refreshCurrentView(),
                new Event("onServerPushRefresh"));
        } catch (Exception ex) {
            log.log(Level.WARNING, "Kanban refresh schedule failed", ex);
        }
    };
    EventManager.getInstance().register(
        RequestKanbanVM.TOPIC_KANBAN_REFRESH, eventSubscriber);
}

Step 5:ページ離脱時に unregister

@Override
public void onPageDetached(Page page) {
    if (eventSubscriber != null) {
        EventManager.getInstance().unregister(eventSubscriber);
        eventSubscriber = null;
    }
    super.onPageDetached(page);
}

これを忘れると、ゾンビ subscriber がリソースを消費し続ける。退職した社員のアカウントを停止し忘れるのと同じ。

Step 6:refreshCurrentView()

public void refreshCurrentView() {
    if (VIEW_GANTT.equals(currentView)) {
        refreshGanttHtml();
        refreshProjectPanel();
    } else {
        refreshKanbanData();
        BindUtils.postNotifyChange(this, "*");
    }
}

3つの鉄則

鉄則間違い正解
Desktop 参照ハンドラ内で this.getDesktop()初期化時に Executions.getCurrent().getDesktop() を保存
イベント配信sendEvent() 同期(ZK スレッド)postEvent() 非同期(OSGi スレッド)
サブスクリプショントピック間違い・unregister 忘れカスタムトピック + ページ離脱時に解除
Ray Lee (System Analyst)
作者 Ray Lee (System Analyst)

iDempeire ERP Contributor, 經濟部中小企業處財務管理顧問 李寶瑞