人員增補申請:從一張表單到一條 Workflow 的完整建置
iDempiere

人員增補申請:從一張表單到一條 Workflow 的完整建置

2026-03-23 · 40 分鐘 · Ray Lee (System Analyst)

HR 拿著一份人員增補申請表走進來,說:「我們需要一個簽核流程。」

工程師接過表單,看了三秒,知道這不只是「建一個 Workflow」。在 iDempiere 的世界裡,每一個欄位都需要名分,每一扇窗口都需要身份,每一條審批鏈都需要從頭蓋起。這不是設定,這是建城。

以下是建城的完整過程。

這次蓋了什麼

先看全局,再進細節。這份建置單是整座城的設計圖:

物件名稱 / 數量
AD_TableHR_HeadcountRequest
AD_Column29 欄(7 系統欄位 + 主鍵 + DocStatus、DocAction、業務欄位)
AD_WindowHeadcount Request(英文主記錄)+ zh_TW Trl = 人員增補申請
AD_TabHeadcount Request(英文主記錄)+ zh_TW Trl = 人員增補申請
AD_Field21 個
AD_MenuHeadcount Request(英文主記錄)+ zh_TW Trl = 人員增補申請
AD_Window_AccessSystem Admin (0)、GardenWorld Admin (102)、GardenWorld Admin Not Advanced (200001)
AD_WorkflowHR_HeadcountRequest
AD_WF_Node9 個(4 系統節點 + 5 審批節點)
AD_WF_NodeNext12 條(3 系統 + 9 審批含駁回)

Workflow 簽核路徑:

         ┌→(DocAuto)
(Start)──┤
         └→(DocPrepare)┬──────────────────────────────────→(DocComplete)
                       ↓                                        ↑
                   申請人→申請人主管→部門主管→總經理→HR主管(CO)──┘
                      ↑_____駁回____↑__________↑__________↑

第一關:文件齊了才開工

每個工程師都有過這種經歷:做到一半,發現需求文件是上個版本的,或者表單樣板根本不存在。人員增補流程不能這樣開始。

所以第一步不是打開資料庫,是打開清單。

開工前需要確認兩類文件:

  • Form Template(表單樣板):Word 或 Excel 格式,定義申請人要填什麼欄位。這份文件決定了後面要建哪些 AD_Column。沒有它,你不知道表單長什麼樣,建出來的窗口就是猜的。
  • Workflow 說明文件:描述審批流程的文件,通常是 Word 或 Excel,說明有幾個關卡、每關由誰審、核准條件是什麼、駁回後走哪條路。這份文件決定了後面 AD_WF_Node 和 AD_WF_NodeNext 怎麼建。

兩份文件缺一不可。Form Template 缺了,你不知道要建什麼;Workflow 說明文件缺了,你不知道節點怎麼連。沒有文件就動手,等於在黑暗中蓋房子——蓋完可能沒有門。

補充邏輯:文件確認後,還要核對以下幾點,才能確保後續順利:

  • 審批層級是否與 HR 或業務單位確認過(不能只看文件,文件可能是舊的)
  • 是否有代理核准的需求(主管出差時由誰代簽)
  • 駁回後是否需要重新走全程,還是只退回特定關卡
  • DB 開發環境帳密是否可用,測試環境與正式環境是否分開




第一點五關:授權儀式

文件齊了,不代表可以開工。還有一個儀式要走完:確認你有進門的鑰匙。

這把鑰匙叫做 DB 連線。在 iDempiere 本機開發環境,資料庫是 PostgreSQL,帳密是 dev / dev,資料庫名稱是 dev,跑在 localhost。聽起來很簡單,但每次換機器、換環境、換 Docker volume,這一步都可能讓你卡半小時。先確認,再動手。

連線確認後,還要驗證 nextid 函式存在且可用。這個函式是 iDempiere 的 PostgreSQL stored function,定義在 adempiere schema 下。它不是標準 SQL,是 iDempiere 自己的東西。如果 schema 沒有 import 完整,或者你連錯了資料庫,這一步就會噴錯,後面所有的 INSERT 全部白費。

授權儀式的三個動作:連線、驗證 nextid、確認目標 Schema。三個都過,才算拿到鑰匙。




— 1. 確認 DB 連線(psql 指令)
— psql -h localhost -U dev -d dev -c “SELECT current_database(), current_user, version();”

— 2. 確認 nextid 函式存在
SELECT routine_name, routine_schema
FROM information_schema.routines
WHERE routine_name = ‘nextid’
AND routine_schema = ‘adempiere’;

— 3. 驗證 nextid 可正常取號(不會實際消耗序列,僅測試)
SELECT nextid(‘AD_Element’, ‘N’) AS test_id;

— 4. 確認 adempiere schema 下有 AD_Element 表
SELECT table_name
FROM information_schema.tables
WHERE table_schema = ‘adempiere’
AND table_name = ‘ad_element’;


第二關:先取號碼牌,再進場

iDempiere 的世界裡,沒有 ID 就沒有身份。每建一筆 AD_* 記錄,都要先跟序列號要一個 Primary Key。這個函式叫 nextid,第一個參數是 Table Name 字串,第二個參數傳 'N'(Client 端序列)或 'Y'(System 序列)。

用法很簡單:直接嵌進 INSERT 的 VALUES 裡。不用先 SELECT,不用存變數,一行搞定。




— nextid 函式用法(tablename 版本)
— nextid(‘TableName’, ‘N’) 直接嵌入 INSERT VALUES
— 第一參數:Table 名稱(對應 AD_Sequence.Name)
— 第二參數:’N’ = Client 端序列(一般用途);’Y’ = System 序列

— 使用範例:
INSERT INTO AD_Element
(AD_Element_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
ColumnName, Name, PrintName)
VALUES
(nextid(‘AD_Element’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100,
‘HR_HeadcountRequest_ID’, ‘Headcount Request’, ‘Headcount Request’);


第三關:命名儀式

在 iDempiere,每個欄位都要有三個名字:資料庫欄位名(DB Column Name)、系統顯示名(Name)、列印標籤(Print Text)。這不是繁瑣,這是規矩。三個名字各司其職,缺一不可。

建立順序有依賴關係,不能亂序:Element → Table → Column → Window → Tab → Field → Menu。就像蓋房子:先有磚頭,才有牆,才有門。

在加入業務欄位之前,有一批 系統標準欄位必須先建。這 7 個欄位是 iDempiere ORM 框架的基礎,少了任何一個,表單開啟後完全無法存入資料:

ColumnNameAD_Reference_ID說明
AD_Client_ID19租戶
AD_Org_ID19組織
IsActive20啟用旗標
Created16建立時間
CreatedBy18建立者
Updated16更新時間
UpdatedBy18更新者

這 7 個欄位對應的 AD_Element 在 iDempiere 裡早已存在,不需要自行建立,只需要用 SELECT AD_Element_ID FROM adempiere.AD_Element WHERE ColumnName='...' 取得 ID 後插入 AD_Column 即可。建議:先用 DO $$ block 一次補齊所有系統欄位,再加業務欄位。

⚠️ 踩坑:系統欄位的 IsUpdateableDefaultValue——不要全設成 IsUpdateable='N'AD_Org_IDIsActive 必須是 'Y',否則 iDempiere 無法儲存新記錄。同時,AD_Client_IDAD_Org_IDDefaultValue 要填 context variable:@#AD_Client_ID@@#AD_Org_ID@。沒設 DefaultValue,新建記錄時這兩欄是空的,存檔必定失敗。

業務欄位中,文件型欄位有兩個特別要注意:DocStatusAD_Reference_ID=17(List 型),必須同時設定 AD_Reference_Value_ID=131,否則下拉選單是空的。DocAction 的正確 AD_Reference_ID28(_Document Action),不是 135——用錯了選單選項會跑掉。建議先查 HR_AbsenceNote 的設定值再套用。此外,所有 Mandatory 欄位都必須設 DefaultValueDocStatus='DR'DocAction='CO'Processed='N'、數字欄位填 0),否則新建記錄無任何錯誤訊息地靜默失敗。

還有兩個容易在後期才踩到的坑:第一,AD_Table INSERT 時要帶入 AD_Window_ID,否則 iDempiere 的 Table and Column 維護畫面的 Window 欄是空的。第二,如果事後補加了 AD_Column,必須同步在 AD_Field 補一筆對應記錄,否則那些欄位在表單上完全不顯示,Mandatory 欄位缺席也會讓存檔失敗。

關於排版:在 AD_Tab 設定 IsSingleRow='Y',Tab 才會預設以 Form(單筆)模式開啟。AD_Field.IsSameLine='Y' 在 Form view 裡沒有效果——並排排版需要在 Window, Tab & Field UI 裡手動拖拉,或透過 XPositionColumnSpanNumLines 欄位設定。

有一條命名規矩必須說清楚:Window、Tab、Menu 的主記錄(AD_WindowAD_TabAD_Menu)Name 欄位要填英文。中文顯示名另外插入對應的 _Trl 翻譯表(AD_Window_TrlAD_Tab_TrlAD_Menu_TrlAD_Language='zh_TW')。直接用中文當主記錄名不符 iDempiere 慣例,日後 Synchronize Terminology 可能覆蓋。

還有一步容易被忽略:Menu 建好後,必須在 AD_TreeNodeMM 插入一筆,指定所屬的 Menu Tree(Tree_ID=10)和上層目錄節點(Parent_ID)。沒有這筆,Menu 在資料庫裡存在,但導覽列上永遠看不到它。

以下是 AD_Element 的命名格式範例,三欄缺一不可:

DB Column NameNamePrint Text
QuotationOrder_IDQuotationQuotation
ReceiveOrderReplyReceive Order ReplyOrder reply
Ref_OrderLine_IDReferenced Order LineRef Order Line
Ref_Order_IDReferenced OrderRef Order
Search_Order_IDSearch OrderSearch Order
SendOrderSend OrderSend Order
SumQtyOrderedSum Qty OrderedSum Qty Ordered
WebOrderEMailWeb Order EMailWeb Order EMail



— 1. AD_Element(欄位元素定義)
— 格式參考 element_naming.jpg:DB Column Name / Name / Print Text
INSERT INTO AD_Element
(AD_Element_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
ColumnName, Name, PrintName, Description)
VALUES
(nextid(‘AD_Element’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100,
‘HR_HeadcountRequest_ID’, ‘Headcount Request’, ‘Headcount Request’, ‘Headcount Request Document’);

— 2. AD_Table
INSERT INTO AD_Table
(AD_Table_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, TableName, IsView, AccessLevel, IsDeleteable, IsHighVolume, IsSecurityEnabled,
IsChangeLog, ReplicationType, AD_Window_ID)
VALUES
(nextid(‘AD_Table’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100,
‘HR Headcount Request’, ‘HR_HeadcountRequest’, ‘N’, ‘3’, ‘Y’, ‘N’, ‘N’, ‘Y’, ‘L’, NULL);

— 3. AD_Column(系統標準欄位 + 主鍵)
— ⚠️ 系統標準欄位缺一不可,否則表單開啟後完全無法存入資料
— ⚠️ Version NOT NULL,必須填 0
— 使用 DO $$ block,先取 Table ID,再批次插入所有欄位

DO $$
DECLARE
v_tbl NUMERIC;
v_seq INTEGER := 10;

— 系統 Element 對應(iDempiere 內建,直接 SELECT 取 ID)
e_client NUMERIC; e_org NUMERIC; e_active NUMERIC;
e_created NUMERIC; e_crby NUMERIC;
e_updated NUMERIC; e_upby NUMERIC;
e_pk NUMERIC; — HR_HeadcountRequest_ID(上方 AD_Element 步驟已建)
BEGIN
SELECT AD_Table_ID INTO v_tbl FROM adempiere.AD_Table WHERE TableName=’HR_HeadcountRequest’;
SELECT AD_Element_ID INTO e_client FROM adempiere.AD_Element WHERE ColumnName=’AD_Client_ID’ AND AD_Client_ID=0;
SELECT AD_Element_ID INTO e_org FROM adempiere.AD_Element WHERE ColumnName=’AD_Org_ID’ AND AD_Client_ID=0;
SELECT AD_Element_ID INTO e_active FROM adempiere.AD_Element WHERE ColumnName=’IsActive’ AND AD_Client_ID=0;
SELECT AD_Element_ID INTO e_created FROM adempiere.AD_Element WHERE ColumnName=’Created’ AND AD_Client_ID=0;
SELECT AD_Element_ID INTO e_crby FROM adempiere.AD_Element WHERE ColumnName=’CreatedBy’ AND AD_Client_ID=0;
SELECT AD_Element_ID INTO e_updated FROM adempiere.AD_Element WHERE ColumnName=’Updated’ AND AD_Client_ID=0;
SELECT AD_Element_ID INTO e_upby FROM adempiere.AD_Element WHERE ColumnName=’UpdatedBy’ AND AD_Client_ID=0;
SELECT AD_Element_ID INTO e_pk FROM adempiere.AD_Element WHERE ColumnName=’HR_HeadcountRequest_ID’;

— 共用欄位 INSERT 巨集(簡化重複)
— 格式:(ID, 0,0,’Y’,now(),100,now(),100, Name, v_tbl, element_id, ColumnName, FieldLen, IsKey, IsParent, IsMandatory, IsTranslated, IsIdentifier, IsEncrypted, RefID, SeqNo, Version)

— 系統欄位 1:AD_Client_ID
INSERT INTO adempiere.AD_Column (AD_Column_ID,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,
Name,AD_Table_ID,AD_Element_ID,ColumnName,FieldLength,IsKey,IsParent,IsMandatory,IsTranslated,IsIdentifier,IsEncrypted,AD_Reference_ID,SeqNo,Version)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Client’,v_tbl,e_client,’AD_Client_ID’,10,’N’,’N’,’Y’,’N’,’N’,’N’,19,v_seq,0);
v_seq := v_seq + 10;

— 系統欄位 2:AD_Org_ID
INSERT INTO adempiere.AD_Column (AD_Column_ID,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,
Name,AD_Table_ID,AD_Element_ID,ColumnName,FieldLength,IsKey,IsParent,IsMandatory,IsTranslated,IsIdentifier,IsEncrypted,AD_Reference_ID,SeqNo,Version)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Organization’,v_tbl,e_org,’AD_Org_ID’,10,’N’,’N’,’Y’,’N’,’N’,’N’,19,v_seq,0);
v_seq := v_seq + 10;

— 系統欄位 3:IsActive
INSERT INTO adempiere.AD_Column (AD_Column_ID,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,
Name,AD_Table_ID,AD_Element_ID,ColumnName,FieldLength,IsKey,IsParent,IsMandatory,IsTranslated,IsIdentifier,IsEncrypted,AD_Reference_ID,SeqNo,Version)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Active’,v_tbl,e_active,’IsActive’,1,’N’,’N’,’Y’,’N’,’N’,’N’,20,v_seq,0);
v_seq := v_seq + 10;

— 系統欄位 4:Created
INSERT INTO adempiere.AD_Column (AD_Column_ID,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,
Name,AD_Table_ID,AD_Element_ID,ColumnName,FieldLength,IsKey,IsParent,IsMandatory,IsTranslated,IsIdentifier,IsEncrypted,AD_Reference_ID,SeqNo,Version)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Created’,v_tbl,e_created,’Created’,7,’N’,’N’,’Y’,’N’,’N’,’N’,16,v_seq,0);
v_seq := v_seq + 10;

— 系統欄位 5:CreatedBy
INSERT INTO adempiere.AD_Column (AD_Column_ID,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,
Name,AD_Table_ID,AD_Element_ID,ColumnName,FieldLength,IsKey,IsParent,IsMandatory,IsTranslated,IsIdentifier,IsEncrypted,AD_Reference_ID,SeqNo,Version)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Created By’,v_tbl,e_crby,’CreatedBy’,10,’N’,’N’,’Y’,’N’,’N’,’N’,18,v_seq,0);
v_seq := v_seq + 10;

— 系統欄位 6:Updated
INSERT INTO adempiere.AD_Column (AD_Column_ID,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,
Name,AD_Table_ID,AD_Element_ID,ColumnName,FieldLength,IsKey,IsParent,IsMandatory,IsTranslated,IsIdentifier,IsEncrypted,AD_Reference_ID,SeqNo,Version)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Updated’,v_tbl,e_updated,’Updated’,7,’N’,’N’,’Y’,’N’,’N’,’N’,16,v_seq,0);
v_seq := v_seq + 10;

— 系統欄位 7:UpdatedBy
INSERT INTO adempiere.AD_Column (AD_Column_ID,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,
Name,AD_Table_ID,AD_Element_ID,ColumnName,FieldLength,IsKey,IsParent,IsMandatory,IsTranslated,IsIdentifier,IsEncrypted,AD_Reference_ID,SeqNo,Version)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Updated By’,v_tbl,e_upby,’UpdatedBy’,10,’N’,’N’,’Y’,’N’,’N’,’N’,18,v_seq,0);
v_seq := v_seq + 10;

— 主鍵欄位:HR_HeadcountRequest_ID
INSERT INTO adempiere.AD_Column (AD_Column_ID,AD_Client_ID,AD_Org_ID,IsActive,Created,CreatedBy,Updated,UpdatedBy,
Name,AD_Table_ID,AD_Element_ID,ColumnName,FieldLength,IsKey,IsParent,IsMandatory,IsTranslated,IsIdentifier,IsEncrypted,AD_Reference_ID,SeqNo,Version)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Headcount Request’,v_tbl,e_pk,’HR_HeadcountRequest_ID’,10,’Y’,’N’,’Y’,’N’,’Y’,’N’,13,v_seq,0);

— 繼續在此 DO block 內加入業務欄位…
END $$;

— 4. AD_Window
— ⚠️ 主記錄 Name 必須用英文,中文顯示靠 AD_Window_Trl
INSERT INTO adempiere.AD_Window
(AD_Window_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, WindowType, IsSOTrx)
VALUES
(nextid(‘AD_Window’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100,
‘Headcount Request’, ‘M’, ‘Y’);

— 4a. AD_Window_Trl(中文譯名)
INSERT INTO adempiere.AD_Window_Trl
(AD_Window_ID, AD_Language, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, IsTranslated)
VALUES
(, ‘zh_TW’, 0, 0, ‘Y’, now(), 100, now(), 100, ‘人員增補申請’, ‘Y’);

— 5. AD_Tab
— ⚠️ 同樣:主記錄英文,中文靠 AD_Tab_Trl
INSERT INTO adempiere.AD_Tab
(AD_Tab_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, AD_Window_ID, AD_Table_ID, SeqNo, TabLevel, IsSingleRow, IsReadOnly, HasTree)
VALUES
(nextid(‘AD_Tab’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100,
‘Headcount Request’, , , 10, 0, ‘N’, ‘N’, ‘N’);

— 5a. AD_Tab_Trl(中文譯名)
INSERT INTO adempiere.AD_Tab_Trl
(AD_Tab_ID, AD_Language, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, IsTranslated)
SELECT AD_Tab_ID, ‘zh_TW’, 0, 0, ‘Y’, now(), 100, now(), 100, ‘人員增補申請’, ‘Y’
FROM adempiere.AD_Tab WHERE AD_Window_ID = ;

— 6. AD_Field(主鍵欄位)
INSERT INTO adempiere.AD_Field
(AD_Field_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, AD_Tab_ID, AD_Column_ID, SeqNo, DisplayLength, IsSameLine, IsHeading,
IsFieldOnly, IsEncrypted, IsDisplayed, IsMandatory)
VALUES
(nextid(‘AD_Field’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100,
‘Headcount Request’, , , 10, 10, ‘N’, ‘N’, ‘N’, ‘N’, ‘Y’, ‘N’);

— 7. AD_Menu
— ⚠️ 主記錄英文,中文靠 AD_Menu_Trl
INSERT INTO adempiere.AD_Menu
(AD_Menu_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, IsSOTrx, IsSummary, Action, AD_Window_ID)
VALUES
(nextid(‘AD_Menu’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100,
‘Headcount Request’, ‘Y’, ‘N’, ‘W’, );

— 7a. AD_Menu_Trl(中文譯名)
INSERT INTO adempiere.AD_Menu_Trl
(AD_Menu_ID, AD_Language, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, IsTranslated)
VALUES
(, ‘zh_TW’, 0, 0, ‘Y’, now(), 100, now(), 100, ‘人員增補申請’, ‘Y’);

— 7b. AD_TreeNodeMM(Menu 必須掛入 Tree,否則不出現在導覽列)
— ⚠️ 少了這步,Menu 建好了但導覽列看不到
— Tree_ID=10 為 Menu Tree;Parent_ID 為上層目錄節點(HR 目錄)
INSERT INTO adempiere.AD_TreeNodeMM
(AD_Tree_ID, Node_ID, Parent_ID, SeqNo, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy)
VALUES
(10, , , 999, 11, 0, ‘Y’, now(), 100, now(), 100);



第四關:給 Admin 一把鑰匙

門蓋好了,窗口設好了,但如果沒有存取權限,所有東西都是不存在的。在 iDempiere,Window 建好後要明確授權給 Role,Admin 才能看到它、操作它。

這裡有個容易踩的坑:只授權 System Admin(Role_ID=0)是不夠的。System Admin 是 Client_ID=0 層級的超級角色,但一般使用者是用 Client 層的帳號登入(例如 GardenWorld Client=11)。如果沒有同時授權目標 Client 的 Admin Role,那些使用者登入後根本看不到這個 Window。授權時要一次給齊:System Admin、目標 Client 的 Admin、以及任何需要存取的角色。




— AD_Window_Access:授予多個 Role 存取 Headcount Request Window
— ⚠️ AD_Window_Access 使用 (AD_Window_ID, AD_Role_ID) 複合主鍵,無獨立 ID 欄位
— ⚠️ 只給 Role_ID=0 不夠!Client 層的使用者看不到 Window,必須補 Client Admin Role
INSERT INTO adempiere.AD_Window_Access
(AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_Window_ID, AD_Role_ID, IsReadWrite)
VALUES
(0, 0, ‘Y’, now(), 100, now(), 100, , 0, ‘Y’), — System Admin
(11, 0, ‘Y’, now(), 100, now(), 100, , 102, ‘Y’), — GardenWorld Admin
(11, 0, ‘Y’, now(), 100, now(), 100, , 200001, ‘Y’); — GardenWorld Admin Not Advanced
— 依實際 Client 調整 AD_Client_ID 與 AD_Role_ID


第五關:Workflow 的四根柱子(System)

每一份 iDempiere 文件 Workflow 都建在同樣的骨架上:Start、DocPrepare、DocComplete、DocAuto。這四個節點定義了文件的生命週期,是 System 層級的結構,不屬於任何 Client。

先建骨架,再掛業務邏輯。這是順序,也是哲學。



— ⚠️ 實際表名:AD_WF_Node(不是 AD_Node),AD_WF_NodeNext(不是 AD_NodeNext)
— ⚠️ 使用 DO $$ 變數 block:節點建立後取回 ID 再做 NodeNext,避免 subquery 返回多筆

DO $$
DECLARE
v_wf_id NUMERIC;
v_start NUMERIC;
v_prep NUMERIC;
v_comp NUMERIC;
v_auto NUMERIC;
BEGIN
— AD_Workflow(Document Workflow)
— ⚠️ AD_Workflow 有多個 NOT NULL 欄位:Cost, PublishStatus, Author, Version, WorkingTime, WaitingTime, IsBetaFunctionality, IsValid
v_wf_id := nextid(‘AD_Workflow’, ‘N’);
INSERT INTO adempiere.AD_Workflow
(AD_Workflow_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AccessLevel, WorkflowType, Duration, DurationUnit,
IsDefault, ValidFrom,
Cost, PublishStatus, Author, Version, WorkingTime, WaitingTime,
IsBetaFunctionality, IsValid)
VALUES
(v_wf_id, 0, 0, ‘Y’, now(), 100, now(), 100,
‘HR Headcount Request’, ‘HR_HeadcountRequest’,
‘3’, ‘D’, 0, ‘D’, ‘Y’, now(),
0, ‘U’, ‘Admin’, 0, 0, 0, ‘N’, ‘N’);

— ⚠️ AD_WF_Node NOT NULL 欄位:Value, JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail
— Action DB 值:B=Start Process, D=Document Action
— DocAction DB 值:PR=Prepare, CO=Complete, –=None

— Node 1: Start
v_start := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES
(v_start, 0, 0, ‘Y’, now(), 100, now(), 100,
‘(Start)’, ‘HHR_Start’, v_wf_id, ‘B’, ‘–‘, 10, 10,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— Node 2: DocAuto(緊接 Start,自動路由)
v_auto := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES
(v_auto, 0, 0, ‘Y’, now(), 100, now(), 100,
‘(DocAuto)’, ‘HHR_Auto’, v_wf_id, ‘D’, ‘–‘, 10, 80,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— Node 3: DocPrepare(審批鏈入口,分接 DocComplete 與申請人)
v_prep := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES
(v_prep, 0, 0, ‘Y’, now(), 100, now(), 100,
‘(DocPrepare)’, ‘HHR_Prepare’, v_wf_id, ‘D’, ‘PR’, 10, 150,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— Node 4: DocComplete(審批鏈收口,HR主管核准後回接此處)
v_comp := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES
(v_comp, 0, 0, ‘Y’, now(), 100, now(), 100,
‘(DocComplete)’, ‘HHR_Complete’, v_wf_id, ‘D’, ‘CO’, 10, 220,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— AD_WF_NodeNext:Transition 連結(所有節點建完後執行)
— Start → DocAuto(第一條出口)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100, v_start, v_auto, 10, ‘N’);

— Start → DocPrepare(第二條出口,同時啟動審批鏈)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100, v_start, v_prep, 20, ‘N’);

— DocPrepare → DocComplete(系統直通路徑)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100, v_prep, v_comp, 10, ‘N’);

END $$;



第六關:五關審批(Client=11)

骨架建好了,現在換業務邏輯上場。人員增補申請有五個審批關卡,每一關都是一個承諾:申請人起單,主管確認,部門主管背書,總經理拍板,HR 執行。

這五個節點建在 Client_ID=11(Demo),掛在同一個 AD_Workflow 下。每個節點需要兩條 Transition:核准走下一關,駁回退回申請人。



— ⚠️ 使用 DO $$ 變數 block,避免 subquery 多筆問題
— ⚠️ 實際表名:AD_WF_Node(非 AD_Node),AD_WF_NodeNext(非 AD_NodeNext)
— ⚠️ AD_WF_Node 有多個 NOT NULL 欄位:Value, JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail
— ⚠️ “limit” 是 PostgreSQL 保留字,INSERT 欄位列表中須加雙引號

DO $$
DECLARE
v_wf_id NUMERIC;
v_prep NUMERIC; — 取得 System 層 DocPrepare 節點 ID(BUILD-WORKFLOW-SYSTEM 已建)
v_comp NUMERIC; — 取得 System 層 DocComplete 節點 ID
v_app NUMERIC; — 申請人
v_mgr NUMERIC; — 申請人主管
v_dept NUMERIC; — 部門主管
v_gm NUMERIC; — 總經理
v_hr NUMERIC; — HR主管
BEGIN
— 取得已建立的 Workflow 和 System 節點 ID
SELECT AD_Workflow_ID INTO v_wf_id FROM adempiere.AD_Workflow WHERE Value=’HR_HeadcountRequest’;
SELECT AD_WF_Node_ID INTO v_prep FROM adempiere.AD_WF_Node WHERE Value=’HHR_Prepare’ AND AD_Workflow_ID=v_wf_id;
SELECT AD_WF_Node_ID INTO v_comp FROM adempiere.AD_WF_Node WHERE Value=’HHR_Complete’ AND AD_Workflow_ID=v_wf_id;

— 五個審批節點(Client_ID=11)
— UserDefined Logic 語法:@ColumnName@ 取欄位值,& = AND,| = OR

— Node 5: 申請人(起單)
v_app := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES (v_app, 11, 0, ‘Y’, now(), 100, now(), 100,
‘申請人’, ‘HHR_Applicant’, v_wf_id, ‘D’, ‘–‘, 200, 10,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— Node 6: 申請人主管
v_mgr := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES (v_mgr, 11, 0, ‘Y’, now(), 100, now(), 100,
‘申請人主管’, ‘HHR_Manager’, v_wf_id, ‘D’, ‘–‘, 200, 80,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— Node 7: 部門主管
v_dept := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES (v_dept, 11, 0, ‘Y’, now(), 100, now(), 100,
‘部門主管’, ‘HHR_DeptHead’, v_wf_id, ‘D’, ‘–‘, 200, 150,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— Node 8: 總經理
v_gm := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES (v_gm, 11, 0, ‘Y’, now(), 100, now(), 100,
‘總經理’, ‘HHR_GM’, v_wf_id, ‘D’, ‘–‘, 200, 220,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— Node 9: HR主管(CO = 核准完成文件)
v_hr := nextid(‘AD_WF_Node’, ‘N’);
INSERT INTO adempiere.AD_WF_Node
(AD_WF_Node_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Value, AD_Workflow_ID, Action, DocAction, XPosition, YPosition,
JoinElement, SplitElement, “limit”, Duration, Cost, WaitingTime, IsAttachedDocumentToEmail)
VALUES (v_hr, 11, 0, ‘Y’, now(), 100, now(), 100,
‘HR主管’, ‘HHR_HR’, v_wf_id, ‘D’, ‘CO’, 200, 290,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);

— AD_WF_NodeNext Transitions(所有節點建完後執行)
— DocPrepare → 申請人(System 銜接 Client 審批鏈)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100, v_prep, v_app, 10, ‘Y’);

— 申請人 → 申請人主管(核准)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_app, v_mgr, 10, ‘Y’);

— 申請人主管 → 部門主管(核准)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_mgr, v_dept, 10, ‘Y’);

— 申請人主管 → 申請人(駁回)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow, EntityType)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_mgr, v_app, 20, ‘N’, ‘U’);

— 部門主管 → 總經理(核准)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_dept, v_gm, 10, ‘Y’);

— 部門主管 → 申請人(駁回)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow, EntityType)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_dept, v_app, 20, ‘N’, ‘U’);

— 總經理 → HR主管(核准)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_gm, v_hr, 10, ‘Y’);

— 總經理 → 申請人(駁回)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow, EntityType)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_gm, v_app, 20, ‘N’, ‘U’);

— HR主管 → DocComplete(核准,銜接回 System 完成文件)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_hr, v_comp, 10, ‘Y’);

— HR主管 → 申請人(駁回,最終關卡仍可退件)
INSERT INTO adempiere.AD_WF_NodeNext
(AD_WF_NodeNext_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
AD_WF_Node_ID, AD_Next_WF_Node_ID, SeqNo, IsStdUserWorkflow, EntityType)
VALUES (nextid(‘AD_WF_NodeNext’,’N’), 11, 0, ‘Y’, now(), 100, now(), 100, v_hr, v_app, 20, ‘N’, ‘U’);

END $$;



⚠️ 踩坑記錄:這些錯誤執行時才會爆

以下是照著本文建置時實際踩到的坑,每一個都是 SQL 報錯換來的教訓。

  1. AD_Column 需要 Version 欄位(NOT NULL)
    原始範例未包含,執行時會報 null value in column "version"。必須加入 Version = 0
  2. AD_Window_Access 沒有 AD_Window_Access_ID 欄位
    這個 table 用 (AD_Window_ID, AD_Role_ID) 複合主鍵,不是獨立序列 ID。不要加 nextid()
  3. Workflow 節點表是 AD_WF_Node,不是 AD_Node
    連結表是 AD_WF_NodeNext,不是 AD_NodeNext。打錯表名,INSERT 直接失敗。
  4. AD_WF_Node 有多個 NOT NULL 欄位須一次填齊
    Value, JoinElement, SplitElement, "limit", Duration, Cost, WaitingTime, IsAttachedDocumentToEmail——缺一即報錯。
  5. AD_Workflow 也有多個 NOT NULL 欄位
    Cost, PublishStatus, Author, Version, WorkingTime, WaitingTime, IsBetaFunctionality, IsValid——原範例只填了一半。
  6. 使用 DO $$ 變數 block 是最佳實踐
    節點建立後取回 ID 再做 NodeNext,避免 subquery 返回多筆的問題。本文已改用此寫法。
  7. limit 是 PostgreSQL 保留字
    在 INSERT 欄位列表中必須加雙引號:"limit",否則 SQL 解析失敗。
  8. Window / Tab / Menu 主記錄名必須用英文,中文靠 _Trl
    直接用中文當 Name 不符合 iDempiere 慣例。主記錄填英文,再分別 INSERT AD_Window_TrlAD_Tab_TrlAD_Menu_Trl,語言碼 zh_TW,才能正確多語系顯示。
  9. AD_Window_Access 只給 System Admin (Role_ID=0) 不夠
    Client 層登入的用戶看不到 Window。必須補上目標 Client 的 Admin Role(例如 GardenWorld Admin Role_ID=102、200001)。AD_Client_ID 也要對應 Client,不能全填 0。
  10. AD_Menu 建好了但導覽列看不到——要插入 AD_TreeNodeMM
    Menu 記錄建立後,必須在 AD_TreeNodeMM 加一筆,指定 Tree_ID(Menu Tree=10)、Parent_ID(上層目錄節點)才會掛進導覽樹。漏了這步,Menu 在資料庫裡存在,但介面上永遠不會出現。
  11. AD_Column 必須包含 7 個系統標準欄位,否則表單完全無法存檔 ⭐
    只加業務欄位,遺漏 iDempiere ORM 必需的系統欄位,表單開啟後無法寫入任何資料。必須先建:AD_Client_ID(Ref=19)、AD_Org_ID(19)、IsActive(20)、Created(16)、CreatedBy(18)、Updated(16)、UpdatedBy(18)。這些 Element 在 iDempiere 已內建,直接 SELECT 取 ID 即可,不需自行建立。
  12. AD_Table INSERT 要帶 AD_Window_ID——否則 Table and Column 畫面 Window 欄空白,維護不便。(詳見第三關)
  13. 所有 Mandatory 欄位必須設 DefaultValue——DocStatus='DR'DocAction='CO'Processed='N' 等,沒設就靜默存檔失敗。(詳見第三關)
  14. DocStatus 需要 AD_Reference_Value_ID=131——List 型下拉選單沒設這個就是空的。(詳見第三關)
  15. DocActionAD_Reference_ID 是 28,不是 135——參考 HR_AbsenceNote 確認。(詳見第三關)
  16. 系統欄位 IsUpdateable / DefaultValue 設錯會讓存檔失敗 ⭐——AD_Org_IDIsActive'Y',前兩個欄位要 context variable。(詳見第三關)
  17. 後補的 AD_Column 要同步補 AD_Field——沒 Field 就不顯示,Mandatory 欄位缺席存檔也會失敗。(詳見第三關)
  18. IsSameLine='Y' 在 Form view 無效;Tab 要設 IsSingleRow='Y'——並排排版要手動拖拉或設 XPosition/ColumnSpan。(詳見第三關)

下一步

城蓋好了,但還沒完工。以下是後續需要處理的項目:

  • ✅ 在 iDempiere UI 確認導覽列出現「人員增補申請」選單(已完成)
  • Synchronize Terminology:在 System Admin → General Rules → System → Synchronize Terminology 執行,讓系統重整欄位翻譯
  • 建立 C_DocType 文件類型並關聯此 Workflow
  • 設定各審批節點的 AD_WF_Responsible(指派實際簽核人)
  • 實作 MHeadcountRequest.java(Model class,可參考 MAbsenceNote 的寫法)

結:雷公曰

一張人員增補表,走過簽查、取名、授權、骨架、審批,終成一條有名有分的 Workflow。
建城不難,難在每一塊磚都要有位置,每一道門都要有鑰匙,每一個節點都要知道下一步去哪。
雷公曰:流程即承諾,程式碼即合約,SQL 即儀式。儀式完成,事成矣。

English

HR walked in with a headcount request form and said: “We need an approval workflow.” The engineer took the form, looked at it for three seconds, and understood: this wasn’t just “set up a workflow.” In iDempiere, every field needs an identity, every window needs a name, every approval chain needs to be built from scratch. This isn’t configuration. This is city-building.

What We Built

ObjectName / Count
AD_TableHR_HeadcountRequest
AD_Column29 columns (7 system columns + PK + DocStatus, DocAction, business fields)
AD_WindowHeadcount Request (English) + zh_TW Trl
AD_TabHeadcount Request (English) + zh_TW Trl
AD_Field21 fields
AD_MenuHeadcount Request (English) + zh_TW Trl
AD_Window_AccessSystem Admin (0), GardenWorld Admin (102), GardenWorld Admin Not Advanced (200001)
AD_WorkflowHR_HeadcountRequest
AD_WF_Node9 nodes (4 system + 5 approval)
AD_WF_NodeNext12 transitions (3 system + 9 approval incl. reject paths)

Approval flow:

         ┌→(DocAuto)
(Start)──┤
         └→(DocPrepare)┬──────────────────────────────────→(DocComplete)
                       ↓                                        ↑
                   Applicant→Direct Mgr→Dept Head→GM→HR Head(CO)┘
                      ↑________reject_______↑__________↑________↑

Gate 1: Documents First

Every engineer has been there: halfway through the build, you discover the requirements doc is from last quarter, or the form template doesn’t exist. A headcount request workflow can’t start that way. The first step isn’t opening the database — it’s opening a checklist.

Gate 2: Get Your Number First

In iDempiere, no ID means no identity. Before inserting any AD_* record, you need a Primary Key from the sequence. The function is called nextid — first parameter is the table name string (e.g. 'AD_Element'), second is 'N' for client-side sequence or 'Y' for system sequence. Usage: embed it directly in the INSERT VALUES. No SELECT first, no variable needed.

Gate 3: The Naming Ceremony

Every field in iDempiere needs three names: DB Column Name, system display Name, and Print Text. The build order has dependencies and cannot be shuffled: Element → Table → Column → Window → Tab → Field → Menu. Like building a house: bricks first, then walls, then doors.

Before adding any business columns, 7 system-required columns must be inserted first. These are the ORM foundation of iDempiere — omit any one of them and the form opens but cannot save anything:

ColumnNameAD_Reference_IDPurpose
AD_Client_ID19Tenant
AD_Org_ID19Organization
IsActive20Active flag
Created16Create timestamp
CreatedBy18Created by user
Updated16Update timestamp
UpdatedBy18Updated by user

The corresponding AD_Element records for these columns already exist in iDempiere. Just SELECT their IDs by ColumnName and use them in the AD_Column INSERT — no need to create them. Use a DO $$ block to handle all columns in one transaction.

⚠️ Gotcha — system column IsUpdateable and DefaultValue: Do not set IsUpdateable='N' on all system columns. AD_Org_ID and IsActive must be 'Y' or iDempiere cannot save new records. Also, AD_Client_ID and AD_Org_ID need context variable defaults: @#AD_Client_ID@ and @#AD_Org_ID@ respectively. Without these, those fields are empty when creating a new record and the save will fail.

Two document-type columns need special attention: DocStatus uses AD_Reference_ID=17 (List type) and must also have AD_Reference_Value_ID=131, otherwise the dropdown is empty. DocAction‘s correct AD_Reference_ID is 28 (_Document Action), not 135 — wrong value causes incorrect dropdown options. Check HR_AbsenceNote first for the correct reference values. Additionally, every Mandatory column must have a DefaultValue (DocStatus='DR', DocAction='CO', Processed='N', numeric fields = 0) — without this, new records silently fail to save with no error message.

Two more pitfalls that often surface later: first, include AD_Window_ID in the AD_Table INSERT — omitting it leaves the Window field blank in the Table and Column maintenance screen. Second, any AD_Column added after the initial build must have a matching AD_Field row; without one, the column is invisible in the form and any Mandatory column without a Field will silently block saves.

On form layout: set AD_Tab.IsSingleRow='Y' to make the tab open in Form mode by default. Note that AD_Field.IsSameLine='Y' has no effect in Form view — side-by-side layout requires manual drag-and-drop in the Window, Tab & Field UI, or configuration via XPosition, ColumnSpan, and NumLines.

One naming rule must be stated clearly: the primary records for Window, Tab, and Menu (AD_Window, AD_Tab, AD_Menu) must use English in the Name field. Chinese display names go into the corresponding translation tables (AD_Window_Trl, AD_Tab_Trl, AD_Menu_Trl) with AD_Language='zh_TW'. Using Chinese as the primary name is against iDempiere convention and may be overwritten by Synchronize Terminology.

One more step that’s easy to miss: after creating the Menu, you must insert a row into AD_TreeNodeMM with the correct Menu Tree ID (Tree_ID=10) and parent folder node (Parent_ID). Without this row, the menu exists in the database but never appears in the navigation bar.

Gate 4: Give Admin a Key

The window is built, but without access permissions it doesn’t exist. After creating the Window, you must explicitly grant role access. Without it, Admin can’t see it or use it.

A common trap: granting access only to System Admin (Role_ID=0) is not enough. System Admin operates at the Client_ID=0 level. Regular users log in under a specific Client (e.g. GardenWorld Client=11). If you don’t also grant access to that Client’s Admin Role, those users will not see the Window at all. Grant all needed roles in one go: System Admin, the target Client’s Admin role, and any other roles that need access.

Gate 5: The Four Pillars (System)

Every iDempiere document workflow is built on the same skeleton: Start, DocPrepare, DocComplete, DocAuto. These four nodes define the document lifecycle at the System level (Client_ID=0). Build the skeleton first, hang the business logic after.

Gate 6: The Five-Gate Approval Chain (Client=11)

The skeleton is ready. Now comes the business logic. The headcount request has five approval gates: Applicant submits, direct manager confirms, department head endorses, GM approves, HR executes. These five nodes are built in Client_ID=11 on the same AD_Workflow. Each node needs two transitions: approve → next gate, reject → back to applicant.

⚠️ Gotchas: Errors You’ll Only Hit at Runtime

These are the actual SQL errors encountered during the build described in this post. Each one cost at least one failed INSERT.

  1. AD_Column requires a Version field (NOT NULL)
    Missing from the original example. Will throw null value in column "version". Must add Version = 0.
  2. AD_Window_Access has no AD_Window_Access_ID column
    This table uses a composite primary key (AD_Window_ID, AD_Role_ID). Do not add a nextid() call for it.
  3. Workflow node table is AD_WF_Node, not AD_Node
    And the transition table is AD_WF_NodeNext, not AD_NodeNext. Wrong table name = immediate INSERT failure.
  4. AD_WF_Node has multiple NOT NULL fields that must all be provided
    Value, JoinElement, SplitElement, "limit", Duration, Cost, WaitingTime, IsAttachedDocumentToEmail — omit any one and the INSERT fails.
  5. AD_Workflow also has multiple hidden NOT NULL fields
    Cost, PublishStatus, Author, Version, WorkingTime, WaitingTime, IsBetaFunctionality, IsValid — the original example only filled half of them.
  6. Use DO $$ variable blocks for workflow node inserts
    Capture node IDs into variables after INSERT, then use them in NodeNext inserts. Avoids subquery-returns-multiple-rows errors.
  7. limit is a reserved word in PostgreSQL
    In INSERT column lists it must be double-quoted: "limit". Otherwise the SQL parser fails.
  8. Window / Tab / Menu primary records must use English names — Chinese goes in _Trl tables
    iDempiere convention: the main record (AD_Window, AD_Tab, AD_Menu) stores the English name. Chinese display is handled by AD_Window_Trl, AD_Tab_Trl, and AD_Menu_Trl with AD_Language='zh_TW'. Skipping the Trl insert means the UI shows English regardless of locale.
  9. Granting AD_Window_Access to System Admin (Role_ID=0) only is not enough
    Users logging in under a Client won’t see the Window. You must also grant access to the Client-level Admin roles (e.g. GardenWorld Admin Role_ID=102, 200001). The AD_Client_ID in those rows must match the target Client, not 0.
  10. After creating AD_Menu, you must insert a row into AD_TreeNodeMM or it won’t appear in the navigation bar
    The Menu record alone is invisible in the UI. Insert a row into AD_TreeNodeMM with the correct AD_Tree_ID (Menu Tree = 10), Node_ID (your Menu ID), and Parent_ID (the parent folder node). Without this, the menu exists in the database but never appears on screen.
  11. AD_Column must include 7 system-required columns — without them the form cannot save any data ⭐
    Adding only business columns and omitting the iDempiere ORM system columns means the form opens but nothing can be written to it. Required columns: AD_Client_ID (Ref=19), AD_Org_ID (19), IsActive (20), Created (16), CreatedBy (18), Updated (16), UpdatedBy (18). These Elements already exist in iDempiere — just SELECT their IDs from AD_Element, no need to create them.
  12. Include AD_Window_ID in the AD_Table INSERT — omitting it leaves the Window field blank in the Table and Column screen. (See Gate 3)
  13. Every Mandatory column needs a DefaultValueDocStatus='DR', DocAction='CO', Processed='N', etc. Without it, new records silently fail to save. (See Gate 3)
  14. DocStatus needs AD_Reference_Value_ID=131 — without it the List-type dropdown is empty. (See Gate 3)
  15. DocAction‘s AD_Reference_ID is 28, not 135 — verify against HR_AbsenceNote. (See Gate 3)
  16. System column IsUpdateable / DefaultValue must be correct ⭐AD_Org_ID and IsActive need 'Y'; AD_Client_ID/AD_Org_ID need context variables. (See Gate 3)
  17. Late-added AD_Column rows need matching AD_Field rows — no Field = invisible in form = silent save failure. (See Gate 3)
  18. IsSameLine='Y' has no effect in Form view; use IsSingleRow='Y' on the Tab — side-by-side layout requires manual UI arrangement or XPosition/ColumnSpan. (See Gate 3)

What’s Next

  • ✅ Verify the “Headcount Request” menu item appears in the iDempiere navigation bar (completed)
  • Synchronize Terminology: run via System Admin → General Rules → System → Synchronize Terminology to rebuild field translations
  • Create a C_DocType document type and link it to this Workflow
  • Configure AD_WF_Responsible for each approval node (assign the actual approvers)
  • Implement MHeadcountRequest.java (Model class — see MAbsenceNote as a reference)

雷公曰

One headcount request form, passed through document check, naming ceremony, access grant, workflow skeleton, and approval chain — finally becoming a Workflow with a name and a place.
Building the city was not the hard part. The hard part was making sure every brick had a position, every door had a key, every node knew where to go next.
Ray says: workflow is promise, code is contract, SQL is ritual. When the ritual is complete, the work is done.

日本語

HR が人員増補申請書を持ってきて言いました:「承認ワークフローが必要です。」エンジニアはその書類を受け取り、3秒見て理解しました。これは単に「ワークフローを設定する」話ではない。iDempiere の世界では、すべてのフィールドに名前が必要で、すべてのウィンドウに身分が必要で、すべての承認チェーンをゼロから構築する必要があります。これは設定ではなく、都市建設です。

今回作ったもの

オブジェクト名前 / 数
AD_TableHR_HeadcountRequest
AD_Column29カラム(システム7カラム + 主キー + DocStatus、DocAction、業務フィールド)
AD_WindowHeadcount Request(英語)+ zh_TW Trl = 人員増補申請
AD_TabHeadcount Request(英語)+ zh_TW Trl = 人員増補申請
AD_Field21フィールド
AD_MenuHeadcount Request(英語)+ zh_TW Trl = 人員増補申請
AD_Window_AccessSystem Admin (0)、GardenWorld Admin (102)、GardenWorld Admin Not Advanced (200001)
AD_WorkflowHR_HeadcountRequest
AD_WF_Node9ノード(システム4 + 承認5)
AD_WF_NodeNext12トランジション(システム3 + 承認9(却下パス含む))

承認フロー:

         ┌→(DocAuto)
(Start)──┤
         └→(DocPrepare)┬──────────────────────────────→(DocComplete)
                       ↓                                    ↑
                   申請者→直属上司→部門長→GM→HR長(CO)────────┘
                      ↑_______却下______↑________↑______↑

第一関:書類から始める

すべてのエンジニアが経験したことがあるはずです:作業の途中で、要件書が古いバージョンだったり、フォームテンプレートが存在しなかったりすることを発見する。人員増補ワークフローはそのような始め方ができません。最初のステップはデータベースを開くことではなく、チェックリストを開くことです。

第二関:番号札を先に取る

iDempiere では、ID がなければ身分がありません。AD_* レコードを挿入する前に、シーケンスから Primary Key を取得する必要があります。関数は nextid と呼ばれ、第一引数は テーブル名の文字列(例:'AD_Element')、第二引数は 'N'(クライアント側シーケンス)または 'Y'(システムシーケンス)です。使い方:INSERT VALUES に直接埋め込みます。

第三関:命名の儀式

iDempiere のすべてのフィールドには三つの名前が必要です:DB カラム名、システム表示名、印刷テキスト。構築順序には依存関係があり、順番を変えることはできません:Element → Table → Column → Window → Tab → Field → Menu。

業務カラムを追加する前に、7 つのシステム必須カラムを先に登録する必要があります。これらは iDempiere ORM の土台であり、1 つでも欠けるとフォームが開いても一切データを保存できません:

ColumnNameAD_Reference_ID用途
AD_Client_ID19テナント
AD_Org_ID19組織
IsActive20有効フラグ
Created16作成日時
CreatedBy18作成者
Updated16更新日時
UpdatedBy18更新者

これらのカラムに対応する AD_Element レコードは iDempiere に既に存在します。ColumnName で ID を SELECT して AD_Column の INSERT に使うだけです。DO $$ ブロックを使ってすべてのカラムを 1 トランザクションで処理することをお勧めします。

⚠️ ハマりポイント — システムカラムの IsUpdateableDefaultValue全カラムに IsUpdateable='N' を設定しないでください。AD_Org_IDIsActive'Y' が必要です。また AD_Client_IDAD_Org_IDDefaultValue にはコンテキスト変数 @#AD_Client_ID@@#AD_Org_ID@ を設定してください。これがないと新規レコード作成時にこれらのフィールドが空のままになり、保存が失敗します。

文書型カラムには追加の注意が必要です:DocStatusAD_Reference_ID=17(List型)ですが、AD_Reference_Value_ID=131 も設定しなければドロップダウンが空になります。DocAction の正しい AD_Reference_ID28(_Document Action)であり、135 は誤りです。設定前に HR_AbsenceNote の値を確認することを推奨します。また、すべての Mandatory カラムに DefaultValue を設定してください(DocStatus='DR'DocAction='CO'Processed='N'、数値カラムは 0)。設定がないと新規レコードの保存がエラーなしにサイレント失敗します。

後から気づきやすい落とし穴を二つ:AD_Table の INSERT には AD_Window_ID を含めてください(省略すると Table and Column 画面の Window 欄が空になります)。また、初期ビルド後にカラムを追加した場合は、対応する AD_Field 行も必ず追加してください——これがないと、そのカラムはフォームに表示されず、Mandatory カラムの欠落が原因でサイレント保存失敗が起きます。

レイアウトについて:AD_Tab.IsSingleRow='Y' を設定するとタブがデフォルトで Form(単票)モードで開きます。AD_Field.IsSameLine='Y' は Form view では効果がありません——横並びレイアウトは Window, Tab & Field の UI で手動配置するか、XPositionColumnSpanNumLines を使って設定します。

命名のルールで必ず押さえておくべき点:Window・Tab・Menu の主レコード(AD_WindowAD_TabAD_Menu)の Name カラムには英語名を入れること。各言語の表示名は対応する翻訳テーブル(AD_Window_TrlAD_Tab_TrlAD_Menu_TrlAD_Language='zh_TW')に登録します。主レコードに中国語を直接入れるのは iDempiere の慣例に反しており、Synchronize Terminology で上書きされる可能性があります。

見落としやすい手順がもう一つ:Menu を作成した後、AD_TreeNodeMM に行を挿入する必要があります。正しい Menu Tree ID(Tree_ID=10)と親フォルダのノード ID(Parent_ID)を指定します。この行がないと、データベース上には Menu が存在していてもナビゲーションバーには一切表示されません。

第四関:Admin に鍵を渡す

ウィンドウは構築されましたが、アクセス権限がなければ存在しないも同然です。Window を作成した後、Role にアクセス権を明示的に付与する必要があります。

よくある落とし穴:System Admin(Role_ID=0)のみに権限を付与しても不十分です。System Admin は Client_ID=0 レベルで動作します。一般ユーザーは特定の Client(例:GardenWorld Client=11)配下でログインします。その Client の Admin Role にも権限を付与しないと、そのユーザーは Window を一切見ることができません。必要なロールをまとめて付与してください:System Admin、対象 Client の Admin Role、その他アクセスが必要なロール。

第五関:四本の柱(System)

すべての iDempiere 文書ワークフローは同じ骨格の上に構築されます:Start、DocPrepare、DocComplete、DocAuto。これら四つのノードは System レベル(Client_ID=0)で文書のライフサイクルを定義します。

第六関:五段階承認チェーン(Client=11)

骨格の準備ができました。今度はビジネスロジックの番です。人員増補申請には五つの承認ゲートがあります:申請者が起票し、直属上司が確認し、部門長が承認し、GM が最終承認し、HR が実行します。各ノードには二つのトランジションが必要です:承認→次のゲート、却下→申請者に差し戻し。

⚠️ ハマりポイント:実行して初めて分かるエラー

以下は実際の建置作業中に踏んだ罠です。いずれも SQL エラーという形でツケが回ってきました。

  1. AD_ColumnVersion フィールドが必要(NOT NULL)
    元のサンプルに含まれておらず、null value in column "version" エラーが発生します。Version = 0 を必ず追加してください。
  2. AD_Window_AccessAD_Window_Access_ID カラムは存在しない
    このテーブルは (AD_Window_ID, AD_Role_ID) の複合主キーを使用します。nextid() は不要です。
  3. ワークフローノードのテーブルは AD_WF_NodeAD_Node ではない)
    トランジションテーブルは AD_WF_NodeNextAD_NodeNext ではない)。テーブル名を間違えると INSERT が即座に失敗します。
  4. AD_WF_Node には複数の NOT NULL フィールドを一度に指定する必要がある
    ValueJoinElementSplitElement"limit"DurationCostWaitingTimeIsAttachedDocumentToEmail — 一つでも欠けると INSERT 失敗。
  5. AD_Workflow にも複数の隠れた NOT NULL フィールドがある
    CostPublishStatusAuthorVersionWorkingTimeWaitingTimeIsBetaFunctionalityIsValid — 元のサンプルはその半分しか含んでいません。
  6. ワークフローノードの INSERT には DO $$ 変数ブロックを使う
    INSERT 後にノード ID を変数に取得してから NodeNext で使用。サブクエリが複数行を返す問題を回避できます。
  7. limit は PostgreSQL の予約語
    INSERT のカラムリストでは二重引用符が必要:"limit"。付けないと SQL パーサーが失敗します。
  8. Window / Tab / Menu の主レコード名は英語にする — 日本語・中国語は _Trl テーブルへ
    iDempiere の慣例として、AD_WindowAD_TabAD_Menu の主レコードには英語名を入れます。各言語の表示名は AD_Window_TrlAD_Tab_TrlAD_Menu_TrlAD_Language='zh_TW' などで登録します。Trl INSERT を省略すると、ロケール設定に関わらず UI は英語表示のままになります。
  9. AD_Window_Access を System Admin (Role_ID=0) のみに付与しても不十分
    Client 配下のユーザーには Window が見えません。対象 Client の Admin Role(例:GardenWorld Admin Role_ID=102、200001)にも付与が必要です。その行の AD_Client_ID は対象 Client の値(0 ではなく)を設定してください。
  10. AD_Menu を作成しても AD_TreeNodeMM に行を挿入しないとナビゲーションバーに表示されない
    Menu レコードだけでは UI に表示されません。AD_TreeNodeMM に正しい AD_Tree_ID(Menu Tree = 10)・Node_ID(作成した Menu ID)・Parent_ID(親フォルダのノード ID)を持つ行を INSERT する必要があります。この手順を省くと、データベース上には存在するのに画面には永遠に表示されません。
  11. AD_Column に 7 つのシステム必須カラムを含めないとフォームにデータを保存できない ⭐
    業務カラムだけを追加して iDempiere ORM が必要とするシステムカラムを省略すると、フォームは開けても一切データを書き込めません。必須カラム:AD_Client_ID(Ref=19)、AD_Org_ID(19)、IsActive(20)、Created(16)、CreatedBy(18)、Updated(16)、UpdatedBy(18)。これらの Element は iDempiere に既に存在するので、AD_Element から ID を SELECT するだけで OK。自分で作成する必要はありません。
  12. AD_Table の INSERT に AD_Window_ID を含める — 省略すると Table and Column 画面の Window 欄が空になります。(第三関を参照)
  13. すべての Mandatory カラムに DefaultValue が必要 ⭐DocStatus='DR'DocAction='CO'Processed='N' など。ないとサイレント保存失敗。(第三関を参照)
  14. DocStatus には AD_Reference_Value_ID=131 が必要 — ないと List 型ドロップダウンが空になります。(第三関を参照)
  15. DocActionAD_Reference_ID は 28(135 は誤り)HR_AbsenceNote で確認を。(第三関を参照)
  16. システムカラムの IsUpdateable / DefaultValue の設定ミスで保存不可 ⭐AD_Org_IDIsActive'Y'、前二者にはコンテキスト変数が必要。(第三関を参照)
  17. 後から追加した AD_Column には AD_Field も必要 — Field がないと表示されず、Mandatory 欠落でサイレント保存失敗。(第三関を参照)
  18. IsSameLine='Y' は Form view では無効;Tab に IsSingleRow='Y' を設定 — 横並びレイアウトは UI での手動配置か XPosition/ColumnSpan が必要。(第三関を参照)

次のステップ

  • ✅ iDempiere のナビゲーションバーに「人員増補申請」メニューが表示されていることを確認(完了)
  • Synchronize Terminology:System Admin → General Rules → System → Synchronize Terminology を実行してフィールド翻訳を再構築
  • C_DocType 文書タイプを作成し、このワークフローに関連付ける
  • 各承認ノードの AD_WF_Responsible を設定(実際の承認者を割り当て)
  • MHeadcountRequest.java を実装(Model class — MAbsenceNote を参考に)

雷公曰

一枚の人員増補申請書が、書類確認、命名の儀式、アクセス付与、ワークフロー骨格、承認チェーンを経て、ついに名前と場所を持つワークフローになりました。

雷公曰:ワークフローは約束であり、コードは契約であり、SQL は儀式です。儀式が完了すれば、仕事は成し遂げられます。

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

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