你的 EndTime 被偷了:iDempiere Model Sandwich Pattern 完全指南 🥪
iDempiere

你的 EndTime 被偷了:iDempiere Model Sandwich Pattern 完全指南 🥪

2026-04-20 · 14 分鐘 · Ray Lee (System Analyst)

序章:你的三明治內餡不見了

某個尋常的下午,你打開 iDempiere,滿心歡喜地在 Kanban 看板上設好了工單的 EndTime,按下儲存——

然後那個欄位就消失了。

不是 UI 的 bug。不是你手殘打錯。資料庫裡確確實實是 NULL

你重做一次。消失。你再重做。還是消失。你盯著螢幕,沉默了三秒,開始懷疑人生。

歡迎進入本文的主題:有人在廚房裡偷吃你三明治的內餡,而且每次都得逞。


第一章:找到那個偷餡料的傢伙

嫌疑犯列表一開始很長。MRequest.beforeSave()?查過了,清白。你的 Plugin 邏輯?翻遍了,沒問題。Kanban Form?無辜。

真正的犯人躲在核心代碼深處,掛著一個無害的名字:

RequestEventHandler,來自 org.adempiere.base

它悄悄登記了 R_Request 的四個存檔事件:

registerTableEvent(IEventTopics.PO_BEFORE_NEW,    I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_BEFORE_CHANGE, I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_AFTER_NEW,     I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_AFTER_CHANGE,  I_R_Request.Table_Name);

每當 PO_BEFORE_CHANGE 觸發,它跑 beforeSaveRequest(),記錄審計歷史、發送通知……然後在最後,毫不猶豫地把你的內餡挖走:

// RequestEventHandler.beforeSaveRequest() — 案發現場
r.setEndTime(null);             // ← 就是這裡,每次都這裡
r.setR_StandardResponse_ID(0);
r.setR_MailText_ID(0);
r.setResult(null);

注意:這段邏輯在每一次 R_Request 存檔時都會執行,不管你改的是哪個欄位。你改了備忘錄?EndTime 消失。你改了狀態?EndTime 消失。你什麼都沒改,只是按了儲存?EndTime 還是消失。

而且我們不能動核心。插件的鐵則。


第二章:廚房的標準作業流程

要打敗一個不講道理的廚房同事,你得先搞清楚廚房的出菜順序。

PO.save() 被呼叫,iDempiere 依照這個順序執行:

PO.save()
  │
  ├─ 1. PO.beforeSave()                    ← 你的 override 在這裡跑(最早)
  │       └─ super.beforeSave()
  │
  ├─ 2. ModelValidationEngine
  │       └─ IModelValidator (TIMING_BEFORE_CHANGE)
  │
  ├─ 3. EventManager.sendEvent(PO_BEFORE_CHANGE)
  │       └─ RequestEventHandler.beforeSaveRequest()
  │               └─ r.setEndTime(null)    ← 內餡在這裡被偷走
  │
  ├─ 4. SQL INSERT / UPDATE                ← EndTime = NULL 寫入資料庫
  │
  ├─ 5. PO.afterSave()                     ← 你的 override 在這裡跑(最後)
  │       └─ super.afterSave()
  │
  ├─ 6. ModelValidationEngine
  │       └─ IModelValidator (TIMING_AFTER_CHANGE)
  │
  └─ 7. EventManager.sendEvent(PO_AFTER_CHANGE)
          └─ RequestEventHandler.afterSaveRequest()

關鍵發現:

  • PO.beforeSave()PO.save() 直接呼叫的 protected 方法,比所有 OSGi 事件都早。
  • RequestEventHandler 是 OSGi EventHandler,只有在 beforeSave() 執行完之後,iDempiere 才發送 PO_BEFORE_CHANGE
  • afterSave() 在 SQL 寫入之後才跑,是最後一道防線。

換句話說:你有一個視窗在竊賊動手之前藏好內餡,還有一個視窗在竊賊離開之後把它塞回去。


第三章:三明治夾擊戰術

解法名字已經說明一切了:

步驟 1 — beforeSave(我們的)
         └─ 把 EndTime 藏進 PO Attribute   ← 在 RequestEventHandler 動手前

步驟 2 — EventManager 發送 PO_BEFORE_CHANGE
         └─ RequestEventHandler 把 EndTime 設為 null

步驟 3 — SQL INSERT / UPDATE
         └─ EndTime = NULL 寫入資料庫

步驟 4 — afterSave(我們的)
         └─ 直接 SQL UPDATE 把 EndTime 還原  ← 在一切結束後

beforeSave 是上層麵包,afterSave 是下層麵包,中間那個把內餡偷走又寫進 DB 的 RequestEventHandler,就是我們要夾住的餡料。

三明治成立,內餡還我。


第四章:食譜(附完整程式碼)

步驟一:登記自訂 Model Factory

表單必須用 MRequestKanban 而不是直接 new MRequest,否則我們的 hooks 根本不會跑。透過 IModelFactory 告訴 iDempiere:碰到 R_Request 給我用這個 class。

OSGI-INF/requestkanban_model_factory.xml 登記:

<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
    name="tw.idempiere.requestkanbanform.factory.RequestKanbanModelFactory">
  <implementation class="tw.idempiere.requestkanbanform.factory.RequestKanbanModelFactory"/>
  <property name="service.ranking" type="Integer" value="100"/>
  <service>
    <provide interface="org.adempiere.base.IModelFactory"/>
  </service>
</scr:component>

service.ranking=100 確保我們的 factory 贏過預設。別忘了在 build.properties 加上這個 XML 檔,不然打包時它會消失:

bin.includes = META-INF/,\
               .,\
               OSGI-INF/requestkanban_form_factory.xml,\
               OSGI-INF/requestkanban_model_factory.xml

步驟二:自訂 Model Class

public class MRequestKanban extends MRequest {

    private static final long serialVersionUID = 1L;
    private static final String END_TIME_ATTR = "Ninniku_OriginalEndTime";

    public MRequestKanban(Properties ctx, int R_Request_ID, String trxName) {
        super(ctx, R_Request_ID, trxName);
    }

    public MRequestKanban(Properties ctx, ResultSet rs, String trxName) {
        super(ctx, rs, trxName);
    }

    @Override
    protected boolean beforeSave(boolean newRecord) {
        // 在 PO_BEFORE_CHANGE 觸發前,先把 EndTime 藏起來
        Object endTime = get_Value("EndTime");
        if (endTime != null) {
            set_Attribute(END_TIME_ATTR, endTime);
        }
        return super.beforeSave(newRecord);
    }

    @Override
    protected boolean afterSave(boolean newRecord, boolean success) {
        boolean ok = super.afterSave(newRecord, success);
        if (success) {
            Object stashedEndTime = get_Attribute(END_TIME_ATTR);
            if (stashedEndTime != null) {
                // 直接 SQL,不能呼叫 saveEx(),否則會無限迴圈
                DB.executeUpdateEx(
                    "UPDATE R_Request SET EndTime=? WHERE R_Request_ID=?",
                    new Object[]{stashedEndTime, getR_Request_ID()},
                    get_TrxName()
                );
                set_ValueNoCheck("EndTime", stashedEndTime); // 讓記憶體狀態同步
            }
        }
        return ok;
    }
}

步驟三:Form 改用自訂 Model

// 以前這樣寫——hooks 從來不跑
MRequest req = new MRequest(Env.getCtx(), requestId, null);

// 現在這樣寫——三明治夾擊啟動
MRequestKanban req = new MRequestKanban(Env.getCtx(), requestId, null);

⚠️ 警告:千萬別在廚房裡再叫一份外送

有人可能會問:afterSave 裡直接呼叫 saveEx() 不就好了?

不行。這樣你就在廚房裡點了外送,外送員進廚房,然後又觸發了一次存檔流程:

afterSave → saveEx() → beforeSave → PO_BEFORE_CHANGE → afterSave → saveEx() → ...  💥

無限迴圈,Stack Overflow,伺服器哭泣。

正確做法是 DB.executeUpdateEx():直接對資料庫下 UPDATE,完全繞過 PO lifecycle。然後用 set_ValueNoCheck() 把記憶體裡的 PO 物件狀態同步,確保後續操作不會讀到舊值。


菜單總結

步驟執行者發生什麼事
1MRequestKanban.beforeSave()EndTime 藏進 PO Attribute
2RequestEventHandler(PO_BEFORE_CHANGE)EndTime 設為 null
3SQLEndTime = NULL 寫入資料庫
4MRequestKanban.afterSave()直接 SQL UPDATE 還原 EndTime
5RequestEventHandler(PO_AFTER_CHANGE)觸發但不動 EndTime

雷公李曰

余嘗觀 iDempiere 廚房,核心事件如行雲流水,順序既定,不可撼動。欲護一欄之值,不可與核心正面交鋒,唯有順勢而為——先藏於 beforeSave,待風頭過後,以直接 SQL 還之。

此法非僅限於 EndTime。凡有 OSGi PO_BEFORE_CHANGE handler 強行清空你欄位者,皆可施此三明治夾擊。

麵包夾好,內餡永在。

English Version

Prologue: Your Sandwich Filling Has Gone Missing

It’s an ordinary afternoon. You open iDempiere, cheerfully set the EndTime on a request ticket via your Kanban board, hit Save — and the field vanishes.

Not a UI glitch. Not a fat-finger moment. The database genuinely says NULL.

You try again. Gone. You try once more. Still gone. You stare at the screen for three seconds and quietly begin to question your career choices.

Welcome to this post’s central mystery: someone in the kitchen is eating your sandwich filling every single time, and they keep getting away with it.


Chapter 1: Identifying the Filling Thief

The suspect list starts long. MRequest.beforeSave()? Checked. Innocent. Your plugin logic? Reviewed. Clean. The Kanban form? Not guilty.

The real culprit is hiding deep in core code, wearing the most harmless name imaginable:

RequestEventHandler, from org.adempiere.base.

It quietly registers itself for four R_Request save events:

registerTableEvent(IEventTopics.PO_BEFORE_NEW,    I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_BEFORE_CHANGE, I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_AFTER_NEW,     I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_AFTER_CHANGE,  I_R_Request.Table_Name);

Every time PO_BEFORE_CHANGE fires, it runs beforeSaveRequest() — auditing changes, sending notifications — and then, without a second thought, cleans out your filling:

// RequestEventHandler.beforeSaveRequest() — the scene of the crime
r.setEndTime(null);             // ← here, every single time
r.setR_StandardResponse_ID(0);
r.setR_MailText_ID(0);
r.setResult(null);

This runs on every R_Request save — regardless of which field you actually changed. Updated the summary? EndTime gone. Changed the status? EndTime gone. Saved without changing anything? EndTime still gone.

And we can’t touch core. Plugin rules. Iron law.


Chapter 2: The Kitchen’s Standard Operating Procedure

To outsmart a kitchen colleague who refuses to play fair, you first need to understand the kitchen’s exact sequence of operations.

When PO.save() is called, iDempiere executes in this order:

PO.save()
  │
  ├─ 1. PO.beforeSave()                    ← your override runs HERE (earliest)
  │       └─ super.beforeSave()
  │
  ├─ 2. ModelValidationEngine
  │       └─ IModelValidator (TIMING_BEFORE_CHANGE)
  │
  ├─ 3. EventManager.sendEvent(PO_BEFORE_CHANGE)
  │       └─ RequestEventHandler.beforeSaveRequest()
  │               └─ r.setEndTime(null)    ← filling stolen HERE
  │
  ├─ 4. SQL INSERT / UPDATE                ← EndTime = NULL written to DB
  │
  ├─ 5. PO.afterSave()                     ← your override runs HERE (last)
  │       └─ super.afterSave()
  │
  ├─ 6. ModelValidationEngine
  │       └─ IModelValidator (TIMING_AFTER_CHANGE)
  │
  └─ 7. EventManager.sendEvent(PO_AFTER_CHANGE)
          └─ RequestEventHandler.afterSaveRequest()

The key insight:

  • PO.beforeSave() is called directly by PO.save() as a protected method — it runs before any OSGi event is dispatched.
  • RequestEventHandler is an OSGi EventHandler; iDempiere only fires PO_BEFORE_CHANGE after beforeSave() has already returned.
  • afterSave() runs after the SQL write — it’s the last line of defense.

Translation: you have a window to hide the filling before the thief strikes, and another window to stuff it back in after they’ve left.


Chapter 3: The Sandwich Pincer Maneuver

The solution’s name already explains everything:

Step 1 — beforeSave (ours)
         └─ stash EndTime into PO Attribute   ← before RequestEventHandler strikes

Step 2 — EventManager fires PO_BEFORE_CHANGE
         └─ RequestEventHandler sets EndTime = null

Step 3 — SQL INSERT / UPDATE
         └─ EndTime = NULL written to DB

Step 4 — afterSave (ours)
         └─ direct SQL UPDATE restores EndTime  ← after everything settles

beforeSave is the top slice of bread. afterSave is the bottom slice. RequestEventHandler — which steals the filling and writes it to the database — is sandwiched between them.

Sandwich assembled. Filling secured.


Chapter 4: The Recipe (Full Code Included)

Step 1: Register a Custom Model Factory

The form must instantiate MRequestKanban rather than MRequest directly. Otherwise our hooks never run. We use IModelFactory to tell iDempiere: whenever you need an R_Request object, use this class instead.

Register in OSGI-INF/requestkanban_model_factory.xml:

<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
    name="tw.idempiere.requestkanbanform.factory.RequestKanbanModelFactory">
  <implementation class="tw.idempiere.requestkanbanform.factory.RequestKanbanModelFactory"/>
  <property name="service.ranking" type="Integer" value="100"/>
  <service>
    <provide interface="org.adempiere.base.IModelFactory"/>
  </service>
</scr:component>

service.ranking=100 ensures our factory wins over the default. And don’t forget to include the XML in build.properties — if you skip this, the file won’t be packaged:

bin.includes = META-INF/,\
               .,\
               OSGI-INF/requestkanban_form_factory.xml,\
               OSGI-INF/requestkanban_model_factory.xml

Step 2: The Custom Model Class

public class MRequestKanban extends MRequest {

    private static final long serialVersionUID = 1L;
    private static final String END_TIME_ATTR = "Ninniku_OriginalEndTime";

    public MRequestKanban(Properties ctx, int R_Request_ID, String trxName) {
        super(ctx, R_Request_ID, trxName);
    }

    public MRequestKanban(Properties ctx, ResultSet rs, String trxName) {
        super(ctx, rs, trxName);
    }

    @Override
    protected boolean beforeSave(boolean newRecord) {
        // Stash EndTime BEFORE PO_BEFORE_CHANGE fires and RequestEventHandler erases it
        Object endTime = get_Value("EndTime");
        if (endTime != null) {
            set_Attribute(END_TIME_ATTR, endTime);
        }
        return super.beforeSave(newRecord);
    }

    @Override
    protected boolean afterSave(boolean newRecord, boolean success) {
        boolean ok = super.afterSave(newRecord, success);
        if (success) {
            Object stashedEndTime = get_Attribute(END_TIME_ATTR);
            if (stashedEndTime != null) {
                // Direct SQL — calling saveEx() here causes an infinite loop
                DB.executeUpdateEx(
                    "UPDATE R_Request SET EndTime=? WHERE R_Request_ID=?",
                    new Object[]{stashedEndTime, getR_Request_ID()},
                    get_TrxName()
                );
                set_ValueNoCheck("EndTime", stashedEndTime); // keep in-memory state in sync
            }
        }
        return ok;
    }
}

Step 3: Use the Custom Model in the Form

// Before — hooks never fire
MRequest req = new MRequest(Env.getCtx(), requestId, null);

// After — sandwich is active
MRequestKanban req = new MRequestKanban(Env.getCtx(), requestId, null);

⚠️ Warning: Don’t Order Another Sandwich Inside the Kitchen

Someone will inevitably ask: why not just call saveEx() inside afterSave?

Because you’d be ordering a delivery to the kitchen. The delivery person enters the kitchen, which triggers another save cycle:

afterSave → saveEx() → beforeSave → PO_BEFORE_CHANGE → afterSave → saveEx() → ...  💥

Infinite loop. Stack overflow. Server crying in the corner.

The correct approach: DB.executeUpdateEx() writes directly to the database table, completely bypassing the PO lifecycle. Then call set_ValueNoCheck() to keep the in-memory PO state consistent so subsequent reads see the right value.


The Menu (Summary)

StepActorWhat happens
1MRequestKanban.beforeSave()Stash EndTime into PO Attribute
2RequestEventHandler (PO_BEFORE_CHANGE)Sets EndTime = null on the PO
3SQLEndTime = NULL written to DB
4MRequestKanban.afterSave()Direct SQL UPDATE restores EndTime
5RequestEventHandler (PO_AFTER_CHANGE)Fires but does not touch EndTime

Master Lei Says

I have long observed the iDempiere kitchen. Core events flow like water — their order fixed, immovable. To protect a single field’s value, one cannot fight the core head-on. One must move with its flow: stash before the storm in beforeSave, restore after the dust settles via direct SQL.

This technique is not limited to EndTime. Wherever an OSGi PO_BEFORE_CHANGE handler forcibly clears your field, the Sandwich Pattern applies.

Bread in position. Filling preserved. Every time.

日本語版

序章:サンドイッチの具が消えた

ある普通の午後、iDempiere を開き、Kanban ボードのチケットに EndTime を喜々として設定して保存ボタンを押したら――

そのフィールドが消えた。

UI のバグでも、入力ミスでもない。データベースには確かに NULL が入っていた。

もう一度やってみた。消えた。また試した。やはり消えた。画面を三秒間じっと見つめて、静かに人生を見つめ直し始めた。

ようこそ、このポストのメインテーマへ:誰かがキッチンでサンドイッチの具を毎回盗み食いしている、そして毎回成功している。


第一章:具を盗む犯人を特定する

容疑者リストは最初長かった。MRequest.beforeSave()?調査済み、シロ。プラグインのロジック?見直した、問題なし。Kanban フォーム?無実。

真犯人はコアコードの奥深くに潜み、無害そうな名前をまとっていた:

RequestEventHandlerorg.adempiere.base より。

これは R_Request の四つの保存イベントにこっそり登録されている:

registerTableEvent(IEventTopics.PO_BEFORE_NEW,    I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_BEFORE_CHANGE, I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_AFTER_NEW,     I_R_Request.Table_Name);
registerTableEvent(IEventTopics.PO_AFTER_CHANGE,  I_R_Request.Table_Name);

PO_BEFORE_CHANGE が発火するたびに beforeSaveRequest() が実行され、変更履歴を記録し、通知を送り――そして最後に、何の迷いもなく具を持ち去る:

// RequestEventHandler.beforeSaveRequest() — 犯行現場
r.setEndTime(null);             // ← ここで、毎回ここで
r.setR_StandardResponse_ID(0);
r.setR_MailText_ID(0);
r.setResult(null);

これは毎回の R_Request 保存で実行される――どのフィールドを変更したかに関係なく。メモを更新した?EndTime が消える。ステータスを変えた?EndTime が消える。何も変えずに保存した?それでも EndTime が消える。

そしてコアには手を触れられない。プラグインの鉄則だ。


第二章:キッチンの標準作業手順

ルールを守らないキッチン仲間を出し抜くには、まずキッチンの出し順を把握しなければならない。

PO.save() が呼ばれると、iDempiere はこの順序で実行する:

PO.save()
  │
  ├─ 1. PO.beforeSave()                    ← あなたの override がここで動く(最初)
  │       └─ super.beforeSave()
  │
  ├─ 2. ModelValidationEngine
  │       └─ IModelValidator (TIMING_BEFORE_CHANGE)
  │
  ├─ 3. EventManager.sendEvent(PO_BEFORE_CHANGE)
  │       └─ RequestEventHandler.beforeSaveRequest()
  │               └─ r.setEndTime(null)    ← ここで具が盗まれる
  │
  ├─ 4. SQL INSERT / UPDATE                ← EndTime = NULL が DB に書き込まれる
  │
  ├─ 5. PO.afterSave()                     ← あなたの override がここで動く(最後)
  │       └─ super.afterSave()
  │
  ├─ 6. ModelValidationEngine
  │       └─ IModelValidator (TIMING_AFTER_CHANGE)
  │
  └─ 7. EventManager.sendEvent(PO_AFTER_CHANGE)
          └─ RequestEventHandler.afterSaveRequest()

重要な発見:

  • PO.beforeSave()PO.save() から直接呼ばれる protected メソッドであり、あらゆる OSGi イベントより前に実行される。
  • RequestEventHandler は OSGi EventHandler であり、iDempiere が PO_BEFORE_CHANGE を発火するのは beforeSave() が返ってきた後だ。
  • afterSave() は SQL 書き込みの後に動く――最後の砦だ。

つまり:犯人が動く前に具を隠す時間があり、犯人が去った後に具を戻す時間もある。


第三章:サンドイッチ挟撃戦術

解法の名前がすべてを物語っている:

ステップ 1 — beforeSave(私たちの)
             └─ EndTime を PO Attribute に隠す   ← RequestEventHandler が動く前に

ステップ 2 — EventManager が PO_BEFORE_CHANGE を発火
             └─ RequestEventHandler が EndTime を null にする

ステップ 3 — SQL INSERT / UPDATE
             └─ EndTime = NULL が DB に書き込まれる

ステップ 4 — afterSave(私たちの)
             └─ 直接 SQL UPDATE で EndTime を復元  ← すべてが終わった後

beforeSave が上のパン、afterSave が下のパン、その間で具を盗んで DB に書き込む RequestEventHandler が挟まれる中身だ。

サンドイッチ完成。具、確保。


第四章:レシピ(完全なコード付き)

ステップ1:カスタム Model Factory を登録する

フォームは MRequest を直接 new するのではなく MRequestKanban を使わなければならない。そうしないとフックが一切動かない。IModelFactory を使って iDempiere に教える:R_Request が必要なときはこのクラスを使え、と。

OSGI-INF/requestkanban_model_factory.xml に登録:

<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
    name="tw.idempiere.requestkanbanform.factory.RequestKanbanModelFactory">
  <implementation class="tw.idempiere.requestkanbanform.factory.RequestKanbanModelFactory"/>
  <property name="service.ranking" type="Integer" value="100"/>
  <service>
    <provide interface="org.adempiere.base.IModelFactory"/>
  </service>
</scr:component>

service.ranking=100 でデフォルトに勝つ。build.properties にこの XML を忘れずに含めること――入れないとパッケージに含まれない:

bin.includes = META-INF/,\
               .,\
               OSGI-INF/requestkanban_form_factory.xml,\
               OSGI-INF/requestkanban_model_factory.xml

ステップ2:カスタム Model クラス

public class MRequestKanban extends MRequest {

    private static final long serialVersionUID = 1L;
    private static final String END_TIME_ATTR = "Ninniku_OriginalEndTime";

    public MRequestKanban(Properties ctx, int R_Request_ID, String trxName) {
        super(ctx, R_Request_ID, trxName);
    }

    public MRequestKanban(Properties ctx, ResultSet rs, String trxName) {
        super(ctx, rs, trxName);
    }

    @Override
    protected boolean beforeSave(boolean newRecord) {
        // PO_BEFORE_CHANGE が発火して RequestEventHandler が消す前に EndTime を隠す
        Object endTime = get_Value("EndTime");
        if (endTime != null) {
            set_Attribute(END_TIME_ATTR, endTime);
        }
        return super.beforeSave(newRecord);
    }

    @Override
    protected boolean afterSave(boolean newRecord, boolean success) {
        boolean ok = super.afterSave(newRecord, success);
        if (success) {
            Object stashedEndTime = get_Attribute(END_TIME_ATTR);
            if (stashedEndTime != null) {
                // 直接 SQL — saveEx() を呼ぶと無限ループになる
                DB.executeUpdateEx(
                    "UPDATE R_Request SET EndTime=? WHERE R_Request_ID=?",
                    new Object[]{stashedEndTime, getR_Request_ID()},
                    get_TrxName()
                );
                set_ValueNoCheck("EndTime", stashedEndTime); // メモリ上の状態を同期
            }
        }
        return ok;
    }
}

ステップ3:フォームでカスタム Model を使う

// 以前——フックが一切動かない
MRequest req = new MRequest(Env.getCtx(), requestId, null);

// 以後——サンドイッチ挟撃が有効
MRequestKanban req = new MRequestKanban(Env.getCtx(), requestId, null);

⚠️ 警告:キッチンの中でまた出前を頼まないこと

afterSave の中で saveEx() を呼べばいいじゃないか」と思う人が必ずいる。

ダメだ。キッチンの中で出前を頼んでいるようなものだ。配達員がキッチンに入り、また保存サイクルが始まる:

afterSave → saveEx() → beforeSave → PO_BEFORE_CHANGE → afterSave → saveEx() → ...  💥

無限ループ。スタックオーバーフロー。サーバーが泣く。

正しいアプローチは DB.executeUpdateEx():PO ライフサイクルを完全に回避してデータベーステーブルに直接書き込む。そして set_ValueNoCheck() でメモリ上の PO 状態を同期し、後続の読み取りが正しい値を参照できるようにする。


メニュー(まとめ)

ステップ実行者何が起きるか
1MRequestKanban.beforeSave()EndTime を PO Attribute に隠す
2RequestEventHandler(PO_BEFORE_CHANGE)PO 上で EndTime = null にする
3SQLEndTime = NULL が DB に書き込まれる
4MRequestKanban.afterSave()直接 SQL UPDATE で EndTime を復元
5RequestEventHandler(PO_AFTER_CHANGE)発火するが EndTime には触れない

雷公李曰く

かつて私は iDempiere のキッチンを観察した。コアのイベントは流水の如く、順序は定まり、動かしがたい。一つのフィールドの値を守るために、コアと正面から戦ってはならない。流れに乗るのだ――嵐の前に beforeSave で隠し、塵が落ち着いた後に直接 SQL で取り戻す。

この技は EndTime だけに使えるわけではない。OSGi の PO_BEFORE_CHANGE ハンドラーがあなたのフィールドを強制的にクリアするところならどこでも、サンドイッチパターンが適用できる。

パン、定位置。具、永久に確保。

Ray Lee (System Analyst)
作者 Ray Lee (System Analyst)

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