HR 拿著一份人員增補申請表走進來,說:「我們需要一個簽核流程。」
工程師接過表單,看了三秒,知道這不只是「建一個 Workflow」。在 iDempiere 的世界裡,每一個欄位都需要名分,每一扇窗口都需要身份,每一條審批鏈都需要從頭蓋起。這不是設定,這是建城。
以下是建城的完整過程。
這次蓋了什麼
先看全局,再進細節。這份建置單是整座城的設計圖:
| 物件 | 名稱 / 數量 |
|---|---|
AD_Table | HR_HeadcountRequest |
AD_Column | 29 欄(7 系統欄位 + 主鍵 + DocStatus、DocAction、業務欄位) |
AD_Window | Headcount Request(英文主記錄)+ zh_TW Trl = 人員增補申請 |
AD_Tab | Headcount Request(英文主記錄)+ zh_TW Trl = 人員增補申請 |
AD_Field | 21 個 |
AD_Menu | Headcount Request(英文主記錄)+ zh_TW Trl = 人員增補申請 |
AD_Window_Access | System Admin (0)、GardenWorld Admin (102)、GardenWorld Admin Not Advanced (200001) |
AD_Workflow | HR_HeadcountRequest |
AD_WF_Node | 9 個(4 系統節點 + 5 審批節點) |
AD_WF_NodeNext | 12 條(3 系統 + 9 審批含駁回) |
Workflow 簽核路徑:
(Start)→(DocPrepare)→(DocComplete)→(DocAuto)
↓
申請人 → 申請人主管 → 部門主管 → 總經理 → 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 框架的基礎,少了任何一個,表單開啟後完全無法存入資料:
| ColumnName | AD_Reference_ID | 說明 |
|---|---|---|
AD_Client_ID | 19 | 租戶 |
AD_Org_ID | 19 | 組織 |
IsActive | 20 | 啟用旗標 |
Created | 16 | 建立時間 |
CreatedBy | 18 | 建立者 |
Updated | 16 | 更新時間 |
UpdatedBy | 18 | 更新者 |
這 7 個欄位對應的 AD_Element 在 iDempiere 裡早已存在,不需要自行建立,只需要用 SELECT AD_Element_ID FROM adempiere.AD_Element WHERE ColumnName='...' 取得 ID 後插入 AD_Column 即可。建議:先用 DO $$ block 一次補齊所有系統欄位,再加業務欄位。
⚠️ 踩坑:系統欄位的 IsUpdateable 和 DefaultValue——不要全設成 IsUpdateable='N'。AD_Org_ID 和 IsActive 必須是 'Y',否則 iDempiere 無法儲存新記錄。同時,AD_Client_ID 和 AD_Org_ID 的 DefaultValue 要填 context variable:@#AD_Client_ID@、@#AD_Org_ID@。沒設 DefaultValue,新建記錄時這兩欄是空的,存檔必定失敗。
業務欄位中,文件型欄位有兩個特別要注意:DocStatus 的 AD_Reference_ID=17(List 型),必須同時設定 AD_Reference_Value_ID=131,否則下拉選單是空的。DocAction 的正確 AD_Reference_ID 是 28(_Document Action),不是 135——用錯了選單選項會跑掉。建議先查 HR_AbsenceNote 的設定值再套用。此外,所有 Mandatory 欄位都必須設 DefaultValue(DocStatus='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 裡手動拖拉,或透過 XPosition、ColumnSpan、NumLines 欄位設定。
有一條命名規矩必須說清楚:Window、Tab、Menu 的主記錄(AD_Window、AD_Tab、AD_Menu)Name 欄位要填英文。中文顯示名另外插入對應的 _Trl 翻譯表(AD_Window_Trl、AD_Tab_Trl、AD_Menu_Trl,AD_Language='zh_TW')。直接用中文當主記錄名不符 iDempiere 慣例,日後 Synchronize Terminology 可能覆蓋。
還有一步容易被忽略:Menu 建好後,必須在 AD_TreeNodeMM 插入一筆,指定所屬的 Menu Tree(Tree_ID=10)和上層目錄節點(Parent_ID)。沒有這筆,Menu 在資料庫裡存在,但導覽列上永遠看不到它。
以下是 AD_Element 的命名格式範例,三欄缺一不可:
| DB Column Name | Name | Print Text |
|---|---|---|
| QuotationOrder_ID | Quotation | Quotation |
| ReceiveOrderReply | Receive Order Reply | Order reply |
| Ref_OrderLine_ID | Referenced Order Line | Ref Order Line |
| Ref_Order_ID | Referenced Order | Ref Order |
| Search_Order_ID | Search Order | Search Order |
| SendOrder | Send Order | Send Order |
| SumQtyOrdered | Sum Qty Ordered | Sum Qty Ordered |
| WebOrderEMail | Web Order EMail | Web 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
(
— 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’,
— 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’,
— 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
(
— 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,
第四關:給 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,
(11, 0, ‘Y’, now(), 100, now(), 100,
(11, 0, ‘Y’, now(), 100, now(), 100,
— 依實際 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: DocPrepare
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, 80,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);
— Node 3: DocComplete
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, 150,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);
— Node 4: DocAuto
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, 220,
‘X’, ‘X’, 0, 0, 0, 0, ‘N’);
— AD_WF_NodeNext:Transition 連結(所有節點建完後執行)
— 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, 10, ‘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’);
— DocComplete → 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_comp, v_auto, 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_auto NUMERIC; — 取得 System 層 DocAuto 節點 ID(BUILD-WORKFLOW-SYSTEM 已建)
v_app NUMERIC; — 申請人
v_mgr NUMERIC; — 申請人主管
v_dept NUMERIC; — 部門主管
v_gm NUMERIC; — 總經理
v_hr NUMERIC; — HR主管
BEGIN
— 取得已建立的 Workflow 和 DocAuto 節點 ID
SELECT AD_Workflow_ID INTO v_wf_id FROM adempiere.AD_Workflow WHERE Value=’HR_HeadcountRequest’;
SELECT AD_WF_Node_ID INTO v_auto FROM adempiere.AD_WF_Node WHERE Value=’HHR_Auto’ 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(所有節點建完後執行)
— DocAuto → 申請人(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_auto, 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主管 → 申請人(駁回,最終關卡仍可退件)
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 報錯換來的教訓。
- AD_Column 需要
Version欄位(NOT NULL)
原始範例未包含,執行時會報null value in column "version"。必須加入Version = 0。 - AD_Window_Access 沒有
AD_Window_Access_ID欄位
這個 table 用(AD_Window_ID, AD_Role_ID)複合主鍵,不是獨立序列 ID。不要加nextid()。 - Workflow 節點表是
AD_WF_Node,不是AD_Node
連結表是AD_WF_NodeNext,不是AD_NodeNext。打錯表名,INSERT 直接失敗。 AD_WF_Node有多個 NOT NULL 欄位須一次填齊
Value,JoinElement,SplitElement,"limit",Duration,Cost,WaitingTime,IsAttachedDocumentToEmail——缺一即報錯。AD_Workflow也有多個 NOT NULL 欄位
Cost,PublishStatus,Author,Version,WorkingTime,WaitingTime,IsBetaFunctionality,IsValid——原範例只填了一半。- 使用
DO $$變數 block 是最佳實踐
節點建立後取回 ID 再做 NodeNext,避免 subquery 返回多筆的問題。本文已改用此寫法。 limit是 PostgreSQL 保留字
在 INSERT 欄位列表中必須加雙引號:"limit",否則 SQL 解析失敗。- Window / Tab / Menu 主記錄名必須用英文,中文靠
_Trl表
直接用中文當 Name 不符合 iDempiere 慣例。主記錄填英文,再分別 INSERTAD_Window_Trl、AD_Tab_Trl、AD_Menu_Trl,語言碼zh_TW,才能正確多語系顯示。 AD_Window_Access只給 System Admin (Role_ID=0) 不夠
Client 層登入的用戶看不到 Window。必須補上目標 Client 的 Admin Role(例如 GardenWorld Admin Role_ID=102、200001)。AD_Client_ID 也要對應 Client,不能全填 0。AD_Menu建好了但導覽列看不到——要插入AD_TreeNodeMM
Menu 記錄建立後,必須在AD_TreeNodeMM加一筆,指定 Tree_ID(Menu Tree=10)、Parent_ID(上層目錄節點)才會掛進導覽樹。漏了這步,Menu 在資料庫裡存在,但介面上永遠不會出現。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 即可,不需自行建立。AD_TableINSERT 要帶AD_Window_ID——否則 Table and Column 畫面 Window 欄空白,維護不便。(詳見第三關)- 所有 Mandatory 欄位必須設
DefaultValue⭐——DocStatus='DR'、DocAction='CO'、Processed='N'等,沒設就靜默存檔失敗。(詳見第三關) DocStatus需要AD_Reference_Value_ID=131——List 型下拉選單沒設這個就是空的。(詳見第三關)DocAction的AD_Reference_ID是 28,不是 135——參考HR_AbsenceNote確認。(詳見第三關)- 系統欄位
IsUpdateable/DefaultValue設錯會讓存檔失敗 ⭐——AD_Org_ID和IsActive要'Y',前兩個欄位要 context variable。(詳見第三關) - 後補的
AD_Column要同步補AD_Field——沒 Field 就不顯示,Mandatory 欄位缺席存檔也會失敗。(詳見第三關) 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
| Object | Name / Count |
|---|---|
AD_Table | HR_HeadcountRequest |
AD_Column | 29 columns (7 system columns + PK + DocStatus, DocAction, business fields) |
AD_Window | Headcount Request (English) + zh_TW Trl |
AD_Tab | Headcount Request (English) + zh_TW Trl |
AD_Field | 21 fields |
AD_Menu | Headcount Request (English) + zh_TW Trl |
AD_Window_Access | System Admin (0), GardenWorld Admin (102), GardenWorld Admin Not Advanced (200001) |
AD_Workflow | HR_HeadcountRequest |
AD_WF_Node | 9 nodes (4 system + 5 approval) |
AD_WF_NodeNext | 12 transitions (3 system + 9 approval incl. reject paths) |
Approval flow:
(Start)→(DocPrepare)→(DocComplete)→(DocAuto)
↓
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:
| ColumnName | AD_Reference_ID | Purpose |
|---|---|---|
AD_Client_ID | 19 | Tenant |
AD_Org_ID | 19 | Organization |
IsActive | 20 | Active flag |
Created | 16 | Create timestamp |
CreatedBy | 18 | Created by user |
Updated | 16 | Update timestamp |
UpdatedBy | 18 | Updated 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.
AD_Columnrequires aVersionfield (NOT NULL)
Missing from the original example. Will thrownull value in column "version". Must addVersion = 0.AD_Window_Accesshas noAD_Window_Access_IDcolumn
This table uses a composite primary key(AD_Window_ID, AD_Role_ID). Do not add anextid()call for it.- Workflow node table is
AD_WF_Node, notAD_Node
And the transition table isAD_WF_NodeNext, notAD_NodeNext. Wrong table name = immediate INSERT failure. AD_WF_Nodehas multiple NOT NULL fields that must all be provided
Value,JoinElement,SplitElement,"limit",Duration,Cost,WaitingTime,IsAttachedDocumentToEmail— omit any one and the INSERT fails.AD_Workflowalso has multiple hidden NOT NULL fields
Cost,PublishStatus,Author,Version,WorkingTime,WaitingTime,IsBetaFunctionality,IsValid— the original example only filled half of them.- 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. limitis a reserved word in PostgreSQL
In INSERT column lists it must be double-quoted:"limit". Otherwise the SQL parser fails.- Window / Tab / Menu primary records must use English names — Chinese goes in
_Trltables
iDempiere convention: the main record (AD_Window,AD_Tab,AD_Menu) stores the English name. Chinese display is handled byAD_Window_Trl,AD_Tab_Trl, andAD_Menu_TrlwithAD_Language='zh_TW'. Skipping the Trl insert means the UI shows English regardless of locale. - Granting
AD_Window_Accessto 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). TheAD_Client_IDin those rows must match the target Client, not 0. - After creating
AD_Menu, you must insert a row intoAD_TreeNodeMMor it won’t appear in the navigation bar
The Menu record alone is invisible in the UI. Insert a row intoAD_TreeNodeMMwith the correctAD_Tree_ID(Menu Tree = 10),Node_ID(your Menu ID), andParent_ID(the parent folder node). Without this, the menu exists in the database but never appears on screen. AD_Columnmust 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 fromAD_Element, no need to create them.- Include
AD_Window_IDin theAD_TableINSERT — omitting it leaves the Window field blank in the Table and Column screen. (See Gate 3) - Every Mandatory column needs a
DefaultValue⭐ —DocStatus='DR',DocAction='CO',Processed='N', etc. Without it, new records silently fail to save. (See Gate 3) DocStatusneedsAD_Reference_Value_ID=131— without it the List-type dropdown is empty. (See Gate 3)DocAction‘sAD_Reference_IDis 28, not 135 — verify againstHR_AbsenceNote. (See Gate 3)- System column
IsUpdateable/DefaultValuemust be correct ⭐ —AD_Org_IDandIsActiveneed'Y';AD_Client_ID/AD_Org_IDneed context variables. (See Gate 3) - Late-added
AD_Columnrows need matchingAD_Fieldrows — no Field = invisible in form = silent save failure. (See Gate 3) IsSameLine='Y'has no effect in Form view; useIsSingleRow='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_DocTypedocument type and link it to this Workflow - Configure
AD_WF_Responsiblefor each approval node (assign the actual approvers) - Implement
MHeadcountRequest.java(Model class — seeMAbsenceNoteas 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_Table | HR_HeadcountRequest |
AD_Column | 29カラム(システム7カラム + 主キー + DocStatus、DocAction、業務フィールド) |
AD_Window | Headcount Request(英語)+ zh_TW Trl = 人員増補申請 |
AD_Tab | Headcount Request(英語)+ zh_TW Trl = 人員増補申請 |
AD_Field | 21フィールド |
AD_Menu | Headcount Request(英語)+ zh_TW Trl = 人員増補申請 |
AD_Window_Access | System Admin (0)、GardenWorld Admin (102)、GardenWorld Admin Not Advanced (200001) |
AD_Workflow | HR_HeadcountRequest |
AD_WF_Node | 9ノード(システム4 + 承認5) |
AD_WF_NodeNext | 12トランジション(システム3 + 承認9(却下パス含む)) |
承認フロー:
(Start)→(DocPrepare)→(DocComplete)→(DocAuto)
↓
申請者 → 直属上司 → 部門長 → 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 つでも欠けるとフォームが開いても一切データを保存できません:
| ColumnName | AD_Reference_ID | 用途 |
|---|---|---|
AD_Client_ID | 19 | テナント |
AD_Org_ID | 19 | 組織 |
IsActive | 20 | 有効フラグ |
Created | 16 | 作成日時 |
CreatedBy | 18 | 作成者 |
Updated | 16 | 更新日時 |
UpdatedBy | 18 | 更新者 |
これらのカラムに対応する AD_Element レコードは iDempiere に既に存在します。ColumnName で ID を SELECT して AD_Column の INSERT に使うだけです。DO $$ ブロックを使ってすべてのカラムを 1 トランザクションで処理することをお勧めします。
⚠️ ハマりポイント — システムカラムの IsUpdateable と DefaultValue:全カラムに IsUpdateable='N' を設定しないでください。AD_Org_ID と IsActive は 'Y' が必要です。また AD_Client_ID と AD_Org_ID の DefaultValue にはコンテキスト変数 @#AD_Client_ID@・@#AD_Org_ID@ を設定してください。これがないと新規レコード作成時にこれらのフィールドが空のままになり、保存が失敗します。
文書型カラムには追加の注意が必要です:DocStatus は AD_Reference_ID=17(List型)ですが、AD_Reference_Value_ID=131 も設定しなければドロップダウンが空になります。DocAction の正しい AD_Reference_ID は 28(_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 で手動配置するか、XPosition・ColumnSpan・NumLines を使って設定します。
命名のルールで必ず押さえておくべき点:Window・Tab・Menu の主レコード(AD_Window・AD_Tab・AD_Menu)の Name カラムには英語名を入れること。各言語の表示名は対応する翻訳テーブル(AD_Window_Trl・AD_Tab_Trl・AD_Menu_Trl、AD_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 エラーという形でツケが回ってきました。
AD_ColumnにVersionフィールドが必要(NOT NULL)
元のサンプルに含まれておらず、null value in column "version"エラーが発生します。Version = 0を必ず追加してください。AD_Window_AccessにAD_Window_Access_IDカラムは存在しない
このテーブルは(AD_Window_ID, AD_Role_ID)の複合主キーを使用します。nextid()は不要です。- ワークフローノードのテーブルは
AD_WF_Node(AD_Nodeではない)
トランジションテーブルはAD_WF_NodeNext(AD_NodeNextではない)。テーブル名を間違えると INSERT が即座に失敗します。 AD_WF_Nodeには複数の NOT NULL フィールドを一度に指定する必要がある
Value、JoinElement、SplitElement、"limit"、Duration、Cost、WaitingTime、IsAttachedDocumentToEmail— 一つでも欠けると INSERT 失敗。AD_Workflowにも複数の隠れた NOT NULL フィールドがある
Cost、PublishStatus、Author、Version、WorkingTime、WaitingTime、IsBetaFunctionality、IsValid— 元のサンプルはその半分しか含んでいません。- ワークフローノードの INSERT には
DO $$変数ブロックを使う
INSERT 後にノード ID を変数に取得してから NodeNext で使用。サブクエリが複数行を返す問題を回避できます。 limitは PostgreSQL の予約語
INSERT のカラムリストでは二重引用符が必要:"limit"。付けないと SQL パーサーが失敗します。- Window / Tab / Menu の主レコード名は英語にする — 日本語・中国語は
_Trlテーブルへ
iDempiere の慣例として、AD_Window・AD_Tab・AD_Menuの主レコードには英語名を入れます。各言語の表示名はAD_Window_Trl・AD_Tab_Trl・AD_Menu_TrlにAD_Language='zh_TW'などで登録します。Trl INSERT を省略すると、ロケール設定に関わらず UI は英語表示のままになります。 AD_Window_Accessを System Admin (Role_ID=0) のみに付与しても不十分
Client 配下のユーザーには Window が見えません。対象 Client の Admin Role(例:GardenWorld Admin Role_ID=102、200001)にも付与が必要です。その行のAD_Client_IDは対象 Client の値(0 ではなく)を設定してください。AD_Menuを作成してもAD_TreeNodeMMに行を挿入しないとナビゲーションバーに表示されない
Menu レコードだけでは UI に表示されません。AD_TreeNodeMMに正しいAD_Tree_ID(Menu Tree = 10)・Node_ID(作成した Menu ID)・Parent_ID(親フォルダのノード ID)を持つ行を INSERT する必要があります。この手順を省くと、データベース上には存在するのに画面には永遠に表示されません。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。自分で作成する必要はありません。AD_Tableの INSERT にAD_Window_IDを含める — 省略すると Table and Column 画面の Window 欄が空になります。(第三関を参照)- すべての Mandatory カラムに
DefaultValueが必要 ⭐ —DocStatus='DR'、DocAction='CO'、Processed='N'など。ないとサイレント保存失敗。(第三関を参照) DocStatusにはAD_Reference_Value_ID=131が必要 — ないと List 型ドロップダウンが空になります。(第三関を参照)DocActionのAD_Reference_IDは 28(135 は誤り) —HR_AbsenceNoteで確認を。(第三関を参照)- システムカラムの
IsUpdateable/DefaultValueの設定ミスで保存不可 ⭐ —AD_Org_ID・IsActiveは'Y'、前二者にはコンテキスト変数が必要。(第三関を参照) - 後から追加した
AD_ColumnにはAD_Fieldも必要 — Field がないと表示されず、Mandatory 欠落でサイレント保存失敗。(第三関を参照) 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 は儀式です。儀式が完了すれば、仕事は成し遂げられます。
