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

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

2026-03-23 最後更新:2026-03-24) · 83 分鐘 · Ray Lee (System Analyst)

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

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

以下是建城的完整過程。

這次蓋了什麼

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

物件名稱 / 數量
AD_TableHR_HeadcountRequest
AD_Column30 欄(7 系統欄位 + UUID(_UU)+ 主鍵 + 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 開發環境帳密是否可用,測試環境與正式環境是否分開



<!–
文件清單:
1. Form Template(Word 或 Excel):人員增補申請表欄位定義
– 範例:QR-AD-06-E.doc / QR-AD-06-E.xlsx
– 確認:欄位名稱、必填/選填、資料型別
2. Workflow 說明文件(Word 或 Excel):審批流程說明
– 確認:關卡數量、各關審批人角色、核准/駁回條件
3. DB 連線可用:dev / dev / dev / localhost(iDempiere 本機開發環境)

審批層級(來自 QR-AD-06-E.txt):
1. 申請人
2. 申請人主管
3. 部門主管
4. 總經理
5. HR主管

補充確認項目:
– 是否有代理核准機制
– 駁回後退回哪個關卡(全程重跑 or 退回特定節點)
– 測試環境 DB 與正式環境 DB 是否已分開

輸出:
PASS: all documents present and verified
MISSING:
UNCONFIRMED:
–>

第一點五關:授權儀式

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

這把鑰匙叫做 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更新者
HR_HeadcountRequest_UU10UUID(FieldLength=36,命名慣例:TableName_UU

這 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——用錯了選單選項會跑掉。建議先查 C_Order 的設定值再套用。此外,所有 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’);

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_UU’, ‘Headcount Request UUID’, ‘Headcount Request UUID’, ‘UUID of Headcount Request’);

— 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);

— 2b. AD_Sequence(PK 序號;手動建表後必補,否則存檔時 SaveError: No NextID)
INSERT INTO adempiere.AD_Sequence
(AD_Sequence_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
Name, Description, IncrementNo, StartNo, CurrentNext, CurrentNextSys, IsTableID, IsAutoSequence, IsAudited)
VALUES
(nextid(‘AD_Sequence’,’N’), 0, 0, ‘Y’, now(), 100, now(), 100,
‘HR_HeadcountRequest’, ‘HR_HeadcountRequest PK Sequence’,
1, 1000000, 1000000, 100, ‘Y’, ‘Y’, ‘N’);

— 2c. 實體 PostgreSQL 資料表
— ⚠️ 必須在 AD_Column 登記前建好;或改用 iDempiere UI 的「Synchronize Column」自動建表
— ⚠️ hr_headcountrequest_uu 需加 UNIQUE constraint(iDempiere Synchronize Column 不會自動補)
CREATE TABLE IF NOT EXISTS adempiere.hr_headcountrequest
(
hr_headcountrequest_id numeric(10,0) NOT NULL,
ad_client_id numeric(10,0) NOT NULL,
ad_org_id numeric(10,0) NOT NULL,
isactive character(1) NOT NULL DEFAULT ‘Y’,
created timestamp NOT NULL DEFAULT now(),
createdby numeric(10,0) NOT NULL,
updated timestamp NOT NULL DEFAULT now(),
updatedby numeric(10,0) NOT NULL,
— 以下為業務欄位,依實際需求新增
docstatus character varying(2) NOT NULL DEFAULT ‘DR’,
docaction character(2) NOT NULL DEFAULT ‘CO’,
processed character(1) NOT NULL DEFAULT ‘N’,
documentno character varying(30),
hr_headcountrequest_uu character varying(36) DEFAULT NULL,
CONSTRAINT hr_headcountrequest_pkey PRIMARY KEY (hr_headcountrequest_id),
CONSTRAINT hr_headcountrequest_uu_idx UNIQUE (hr_headcountrequest_uu)
);
ALTER TABLE IF EXISTS adempiere.hr_headcountrequest OWNER TO adempiere;

— 3. AD_Column(系統標準欄位 + 主鍵)
— ⚠️ 系統標準欄位缺一不可,否則表單開啟後完全無法存入資料
— ⚠️ Version NOT NULL,必須填 0
— ⚠️ IsUpdateable / DefaultValue 已含入各欄位 VALUES,請勿省略
— 使用 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 步驟已建)
e_uuid NUMERIC; — HR_HeadcountRequest_UU(UUID,命名慣例:TableName_UU)
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’;
SELECT AD_Element_ID INTO e_uuid FROM adempiere.AD_Element WHERE ColumnName=’HR_HeadcountRequest_UU’;

— 共用欄位 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,IsUpdateable,DefaultValue)
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,’N’,’@#AD_Client_ID@’);
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,IsUpdateable,DefaultValue)
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,’Y’,’@#AD_Org_ID@’);
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,IsUpdateable,DefaultValue)
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,’Y’,’Y’);
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,IsUpdateable,DefaultValue)
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,’N’,NULL);
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,IsUpdateable,DefaultValue)
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,’N’,NULL);
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,IsUpdateable,DefaultValue)
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,’N’,NULL);
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,IsUpdateable,DefaultValue)
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,’N’,NULL);
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,IsUpdateable,DefaultValue)
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,’N’,NULL);
v_seq := v_seq + 10;

— UUID 欄位(命名慣例:TableName_UU;必建,缺了存檔靜默失敗)
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,IsUpdateable,DefaultValue)
VALUES (nextid(‘AD_Column’,’N’),0,0,’Y’,now(),100,now(),100,’Headcount Request UUID’,v_tbl,e_uuid,’HR_HeadcountRequest_UU’,36,’N’,’N’,’N’,’N’,’N’,’N’,10,v_seq,0,’N’,NULL);
v_seq := v_seq + 10;

— 繼續在此 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

<!– VERIFY: SELECT * FROM AD_Window_Access WHERE AD_Role_ID=0 AND AD_Window_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 $$;


<!– VERIFY:
SELECT n.Name, nn.SeqNo, n2.Name as NextNode
FROM adempiere.AD_WF_NodeNext nn
JOIN adempiere.AD_WF_Node n ON n.AD_WF_Node_ID=nn.AD_WF_Node_ID
JOIN adempiere.AD_WF_Node n2 ON n2.AD_WF_Node_ID=nn.AD_Next_WF_Node_ID
WHERE n.AD_Workflow_ID=
ORDER BY n.YPosition, nn.SeqNo;
–>

第七關:補齊 Document 引擎必備欄位

五關審批蓋好了,但這份申請表還不算一份真正的「文件」。iDempiere 的 Document Engine 要接管一份表單,表單的實體資料表裡必須存在兩個欄位:C_DocType_ID(文件類型)和 Processing(觸發處理的按鈕欄位)。

Phase 1 建欄位時已補了 DocStatusDocActionProcessedDocumentNo,但 C_DocType_IDProcessing 往往被遺漏。少了這兩個,Document Engine 找不到文件類型就不知道要套哪條 Workflow,Processing 欄位缺席則按鈕無法觸發。

補法分兩步:先在資料庫實體加欄位,再進 AD_ColumnAD_Field 登記讓 iDempiere 認識它。



— Step 1:在實體資料表補欄位
ALTER TABLE adempiere.HR_HeadcountRequest ADD COLUMN IF NOT EXISTS C_DocType_ID NUMERIC(10) DEFAULT NULL;
ALTER TABLE adempiere.HR_HeadcountRequest ADD COLUMN IF NOT EXISTS Processing CHAR(1) DEFAULT ‘N’ NOT NULL;

— Step 2:註冊至 AD_Column
DO $$
DECLARE
v_tbl NUMERIC;
v_e_doctype NUMERIC;
v_e_proc NUMERIC;
v_seq INTEGER;
BEGIN
SELECT AD_Table_ID INTO v_tbl FROM adempiere.AD_Table WHERE TableName=’HR_HeadcountRequest’;
SELECT AD_Element_ID INTO v_e_doctype FROM adempiere.AD_Element WHERE ColumnName=’C_DocType_ID’ AND AD_Client_ID=0 LIMIT 1;
SELECT AD_Element_ID INTO v_e_proc FROM adempiere.AD_Element WHERE ColumnName=’Processing’ AND AD_Client_ID=0 LIMIT 1;
SELECT COALESCE(MAX(SeqNo),0)+10 INTO v_seq FROM adempiere.AD_Column WHERE AD_Table_ID=v_tbl;

— C_DocType_ID(Reference=19 Table Direct,指向 C_DocType)
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,
‘Document Type’,v_tbl,v_e_doctype,’C_DocType_ID’,10,’N’,’N’,’N’,’N’,’N’,’N’,19,v_seq,0);
v_seq := v_seq + 10;

— Processing(Reference=28 Button)
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,
‘Process Now’,v_tbl,v_e_proc,’Processing’,1,’N’,’N’,’N’,’N’,’N’,’N’,28,v_seq,0);
END $$;

— Step 3:補 AD_Field(讓欄位出現在表單)
DO $$
DECLARE
v_tab_id NUMERIC;
v_seq INTEGER;
col RECORD;
BEGIN
SELECT AD_Tab_ID INTO v_tab_id FROM adempiere.AD_Tab WHERE Name=’Headcount Request’ AND AD_Window_ID=1000449;
SELECT COALESCE(MAX(SeqNo),0) INTO v_seq FROM adempiere.AD_Field WHERE AD_Tab_ID=v_tab_id;
FOR col IN
SELECT c.AD_Column_ID, c.Name
FROM adempiere.AD_Column c
LEFT JOIN adempiere.AD_Field f ON f.AD_Column_ID=c.AD_Column_ID AND f.AD_Tab_ID=v_tab_id
WHERE c.AD_Table_ID=(SELECT AD_Table_ID FROM adempiere.AD_Table WHERE TableName=’HR_HeadcountRequest’)
AND c.ColumnName IN (‘C_DocType_ID’,’Processing’)
AND f.AD_Field_ID IS NULL
LOOP
v_seq := v_seq + 10;
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,
col.Name,v_tab_id,col.AD_Column_ID,v_seq,60,’N’,’N’,’N’,’N’,’Y’,’N’);
END LOOP;
END $$;

第八關:建立 C_DocType,並在 AD_Workflow 設定 Table 綁定

文件類型(C_DocType)決定這份表單的單號格式與列印版型。要注意:標準版 iDempiere 的 C_DocType 沒有 Workflow 欄位——Workflow 不是在 C_DocType 上設定,而是在 AD_Workflow 透過 AD_Table_IDWorkflowType='D' 來綁定。Document Engine 在執行時會查找 AD_WorkflowAD_Table_ID 符合、WorkflowType='D'IsDefault='Y' 的那筆記錄,作為該 Table 的文件流程。

Step 1:新增 DocBaseType 自訂值

在 iDempiere UI 中:System Admin → General Rules → Reference → Reference,找到 C_DocType DocBaseType,在 List 中加入:

  • ValueHRC
  • Name:HR Headcount Request

Step 2:建立單號 Sequence

System Admin → General Rules → System → Document Sequence,新建一筆:

  • Name:HC Sequence
  • PrefixHC-
  • Start No:1、Increment:1、Decimal Pattern000000(產生 HC-000001)

Step 3:建立 C_DocType 記錄

System Admin → General Rules → Document Type,新建一筆:

  • Name:Headcount Request
  • DocBaseType:HRC
  • GL Category:可留空
  • Document Sequence:選上方建立的 HC Sequence

建好後,把 C_DocType_ID 的值填入 AD_Column.DefaultValue,讓新單據自動帶入文件類型。

Step 4:在 AD_Workflow 設定 Table 綁定

Workflow 與 Table 的綁定在 AD_Workflow 記錄本身設定。確認 HR_HeadcountRequest 這筆 Workflow 有以下設定:

  • WorkflowTypeD(Document)
  • AD_Table_ID:指向 HR_HeadcountRequest Table
  • IsDefaultY
UPDATE adempiere.AD_Workflow
SET WorkflowType = 'D',
    AD_Table_ID  = (SELECT AD_Table_ID FROM adempiere.AD_Table WHERE TableName='HR_HeadcountRequest'),
    IsDefault    = 'Y'
WHERE Value = 'HR_HeadcountRequest';

設定完成後,Document Engine 在處理 HR_HeadcountRequest 表單時,會自動找到這條 Workflow 並啟動。

Step 5:綁定 DocAction 按鈕與 AD_Process(三部曲收尾)

⚠️ 踩坑 #20: DocAction 欄位雖然看起來是一個按鈕,但它無法直接觸發 Workflow。iDempiere 的設計是透過 AD_Process 作為橋樑。若沒有完成這個串接,按下 Document Action 按鈕毫無反應。

完整的三部曲串接:

  1. AD_Workflow 已設定 AD_Table_ID(Step 4 完成)
  2. Report & Process(System Admin → General Rules → Process)建立一支專屬 Process:
    • Name / ValueProcess HR_HeadcountRequest
    • Workflow:選擇 HR_HeadcountRequest
  3. 回到 Table & Column,找到 HR_HeadcountRequestDocAction 欄位,將 Process 欄位指向上一步建立的 Process。
-- 若 AD_Process 已建立,SQL 方式直接綁定:
UPDATE adempiere.AD_Column
SET AD_Process_ID = (SELECT AD_Process_ID FROM adempiere.AD_Process WHERE Value='Process HR_HeadcountRequest')
WHERE ColumnName='DocAction'
  AND AD_Table_ID=(SELECT AD_Table_ID FROM adempiere.AD_Table WHERE TableName='HR_HeadcountRequest');

完成三部曲後,操作者在表單點擊 Document Action 按鈕時,系統才能透過 Process 找到對應的 Workflow 並順利派發簽核。

第九關:指派簽核負責人(AD_WF_Responsible)

Workflow 節點建好了,但每個需要人工介入的節點還需要一張「值班表」——AD_WF_Responsible——告訴系統這關要派給誰。沒有指派,系統不知道要把待辦送到誰的收件匣。

三種常見的 ResponsibleType:

Type代碼說明
Human / InvokerH指派給發起人本身(起單的人就是申請人)
RoleR依 Role 指派,由該 Role 下任一有權限的使用者處理
OrganizationO指派給組織主管

本流程的指派策略:

  • (DocPrepare)(DocComplete):系統節點,無須指派
  • 申請人 節點:ResponsibleType=H(Invoker,起單人自己)
  • 申請人主管部門主管總經理HR主管:建立對應的 AD_WF_Responsible(Type=R),綁定至相應 Role

在 iDempiere UI 中:System Admin → General Rules → Workflow → Workflow Responsible,為每個審批角色建一筆記錄,再回到 Window, Tab & Field → Workflow → Node,在各節點的 Responsible 欄位選入對應記錄。

第十關:實作 MHeadcountRequest.java

資料庫結構建好了,C_DocType 建好了,Workflow 綁好了——但 iDempiere Document Engine 要能真正驅動這份表單,還需要一個 Model Class。沒有它,按下 Complete 的時候系統不知道要做什麼驗證、不知道要觸發哪些業務邏輯。

流程:先用 GenerateModelHR_HeadcountRequest 產生 X_HR_HeadcountRequest.java(基底類別),再繼承它建立 MHeadcountRequest.java,實作以下三個核心方法:

1. customizeValidActions()

定義每個文件狀態下可以觸發哪些動作。例如草稿(DR)狀態只能 Complete;已完成(CO)只能 Void 或 Re-activate:

@Override
protected String[] customizeValidActions(String docStatus, Object processing,
    String orderType, String isSOTrx, int AD_Table_ID, String[] validActions) {
  if (DocAction.STATUS_Drafted.equals(docStatus))
    return new String[] { DocAction.ACTION_Complete };
  if (DocAction.STATUS_Completed.equals(docStatus))
    return new String[] { DocAction.ACTION_Void, DocAction.ACTION_ReActivate };
  return validActions;
}

2. prepareIt()

提交前驗證必填欄位。若驗證失敗,回傳 STATUS_Invalid,iDempiere 會阻止流程繼續:

@Override
public String prepareIt() {
  if (getDeptName() == null || getDeptName().isEmpty()) {
    m_processMsg = "申請部門不可空白";
    return DocAction.STATUS_Invalid;
  }
  // 其他驗證...
  m_processMsg = ModelValidationEngine.get().fireDocValidate(this, ModelValidator.TIMING_BEFORE_PREPARE);
  if (m_processMsg != null) return DocAction.STATUS_Invalid;
  setDocStatus(DocAction.STATUS_InProgress);
  return DocAction.STATUS_InProgress;
}

3. processIt()

轉交 Document Engine 處理,由 iDempiere 核心接管 Workflow 推動:

@Override
public boolean processIt(String action) {
  m_processMsg = null;
  return DocumentEngine.processIt(this, action);
}

4. 註冊 ModelFactory

最後將 MHeadcountRequest 註冊到外掛的 MyModelFactory.java,iDempiere 才能在讀取 HR_HeadcountRequest 記錄時實體化正確的模型物件:

@Override
public PO getPO(String tableName, int Record_ID, String trxName) {
  if ("HR_HeadcountRequest".equals(tableName))
    return new MHeadcountRequest(Env.getCtx(), Record_ID, trxName);
  return null;
}

四個步驟走完,Document Engine 才算真正接手這份人員增補申請表,從草稿到核准,每一步都有法可依。

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

以下是照著本文建置時實際踩到的坑,每一個都是 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——參考 C_Order 確認。(詳見第三關)
  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。(詳見第三關)
  19. AD_Workflow 必須設定 AD_Table_ID 才能定位表單 ⭐——WorkflowType='D'IsDefault='Y' 缺一不可,但最常被遺忘的是 AD_Table_ID。沒設,Document Engine 完全找不到這條 Workflow。(詳見第八關 Step 4)
  20. DocAction(Button)必須透過 AD_Process 橋樑才能觸發 Workflow ⭐——光設 Workflow 還不夠,按鈕不會自動觸發。必須建立一支 AD_Process 指向該 Workflow,再把這支 Process 綁回 DocAction 的 AD_Column。少這一步,按鈕永遠沒反應。(詳見第八關 Step 5)
  21. 未執行 Synchronize Terminology 導致介面顯示英文
    透過 SQL 建置大量 AD_Column/AD_Field 後,即使 AD_Element 定義了中文名稱,iDempiere 也不會自動建立對應的 _Trl 記錄。登入中文介面時,所有欄位標籤仍顯示英文。解法:先確認 AD_Element_Trl 已有 zh_TW 翻譯,再以 System Administrator 身分執行 Synchronize Terminology——系統會自動將 Element 翻譯同步寫入 AD_Column_TrlAD_Field_Trl,確保中文介面正確顯示。
  22. UUID 欄位是必建系統欄位,缺了存檔靜默失敗 ⭐
    iDempiere 的每張資料表都需要一個 UUID 欄位,命名慣例是 TableName_UU(本例為 HR_HeadcountRequest_UU),AD_Reference_ID=10FieldLength=36。若在 AD_Column 漏加此欄,存檔時不會顯示錯誤訊息,但記錄根本沒寫入資料庫。
  23. 手動建 AD_Table 後未在 AD_Sequence 補 PK 序號 → 存檔出現 SaveError (No NextID)
    透過 SQL 直接插入 AD_Table 後,必須在 AD_Sequence 手動新增一筆對應的序號記錄;否則儲存新記錄時會拋出 GenericPO.saveNew: No NextID (-1) 錯誤。
  24. 手寫 X_ 類別時 Table_ID 寫死 → 存檔出現 ColumnNotFound
    ModelGenerator 產生的 X_ 類別通常把 Table_ID 寫死為當時的值(例如 1000000)。若資料庫環境不同,這個 ID 可能對應到別的表,導致出現如 ColumnNotFound - SK_Unit.DocStatus 的錯誤。應改用 MTable.getTable_ID(Table_Name) 動態取得,或在部署後確認 ID 是否一致。
  25. Toolbar 無法顯示 Document Action 按鈕——WindowType 必須是 T(Transaction)
    設定完 DocActionAD_Process 等所有表單環境後,若 Toolbar 仍找不到「Document Action(齒輪)」按鈕,通常是因為 AD_Window.WindowType 設定錯誤。手寫 SQL 時預設可能填入 M(Maintain),但具備 Document Engine 與 Workflow 的表單必須是 Transaction 類型,按鈕才會出現在 Toolbar 控制列中。

    UPDATE adempiere.AD_Window SET WindowType = 'T' WHERE name = 'Headcount Request';
  26. Toolbar 無法顯示 Document Action 按鈕——缺少 AD_Process_Access 角色權限
    即使 WindowType='T' 設定正確,若 Toolbar 上的 Document Action 仍不出現,原因通常是缺少角色權限。iDempiere 會檢查 DocAction 欄位綁定的 AD_Process 是否對當前登入角色開放。手寫 SQL 建立的 AD_Process 預設沒有任何角色有權限(AD_Process_Access 為空),系統就會悄悄隱藏 Toolbar 按鈕。需到 System Admin → Role → Process Access 開放,或直接執行:

    INSERT INTO adempiere.AD_Process_Access (AD_Process_ID, AD_Role_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, IsReadWrite)
    VALUES ((SELECT AD_Process_ID FROM adempiere.AD_Process WHERE Value='Process HR_HeadcountRequest'), 102, 11, 0, 'Y', now(), 100, now(), 100, 'Y');
  27. Toolbar 無法顯示 Document Action 按鈕——缺少 AD_Document_Action_Access(單據動作權限)⭐
    即使 WindowType='T'AD_ProcessAD_Process_Access 全都設好,若環境啟用了單據動作權限控管,系統還會對各 Role 規範在特定 C_DocType 下能執行哪些 DocAction。手動建置的 C_DocType 預設不會出現在任何 Role 的權限清單中(AD_Document_Action_Access 為空),Toolbar 按鈕仍然被隱藏。需針對目標 C_DocType 及 Role 補齊對應 Action 的記錄:

    INSERT INTO adempiere.AD_Document_Action_Access (
       AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
       C_DocType_ID, AD_Role_ID, AD_Ref_List_ID, AD_Document_Action_Access_UU
    )
    SELECT 11, 0, 'Y', now(), 100, now(), 100,
       (SELECT C_DocType_ID FROM adempiere.C_DocType WHERE Name='Headcount Request' LIMIT 1),
       r.ad_role_id, l.ad_ref_list_id, gen_random_uuid()::text
    FROM adempiere.AD_Ref_List l
    CROSS JOIN (SELECT 102 AS ad_role_id UNION SELECT 0) r
    WHERE l.ad_reference_id = 135 AND l.isactive='Y';
  28. 啟動 Workflow 爆錯 Invalid Action (Not Implemented) =B 與簽核節點不進收件匣
    用純 SQL 建置 AD_WF_Node 時,極容易填錯 Action 欄位的代碼:

    • Invalid Action =B:送出單據時出現此錯誤,通常是因為 (Start) 節點填了不存在的代碼。進入點或暫停等待的節點正確代碼是 Z(Wait/Sleep)。
    • 簽核節點直接跳過不停下來:若「人工簽核」節點(申請人主管、部門主管等)的 Action 被誤設為 D(Document Action),Workflow 引擎會當成系統自動動作處理,完全不會停下來送到主管收件匣等候 Approve。

    解法:人工簽核節點改為 Action='C'(User Choice)並設定 IsUserApproval='Y';進入點 (Start) 改為 Action='Z'

    -- 修正人工簽核節點 Action
    UPDATE adempiere.AD_WF_Node
    SET Action = 'C', IsUserApproval = 'Y'
    WHERE AD_Workflow_ID = (SELECT AD_Workflow_ID FROM adempiere.AD_Workflow WHERE Value='HR_HeadcountRequest')
      AND Name IN ('申請人','申請人主管','部門主管','總經理','HR主管');
    
    -- 修正 Start 節點
    UPDATE adempiere.AD_WF_Node
    SET Action = 'Z'
    WHERE AD_Workflow_ID = (SELECT AD_Workflow_ID FROM adempiere.AD_Workflow WHERE Value='HR_HeadcountRequest')
      AND Name = '(Start)';
  29. 手寫 Model Class 忘記 import 導致編譯失敗 (cannot find symbol)
    手工建立 MHeadcountRequest.java 並在 prepareIt() / completeIt() 中呼叫文件驗證事件時,最容易忘記加入 import,導致 ant build 階段出現 cannot find symbol 編譯失敗。

    import org.compiere.model.ModelValidationEngine;
    import org.compiere.model.ModelValidator;

    沒有其他解法,就是補上 import。

  30. DocAction 送簽按鈕離奇消失——SeqNo=0IsDisplayed='N' 會讓 Toolbar 找不到它 ⭐
    為了讓表單畫面乾淨,許多人習慣把系統欄位(CreatedUpdated 等)設為 IsDisplayed='N' 並將 SeqNo=0。但千萬不能把 DocAction 也這樣處理!ZK WebUI 在繪製 GridTab 時,只會實體化 SeqNo > 0IsDisplayed='Y' 的 AD_Field,一旦 DocAction 被排除,Toolbar 就找不到它,送簽齒輪按鈕直接憑空消失。
    正確做法是比照標準表單(如 Sales Order),給 DocAction 一個極大的 SeqNo(例如 999),並依需求設定 IsToolbarButton

    -- 'B' = 同時顯示在 Toolbar 與 Form 表單內 (Sales Order 風格)
    -- 'Y' = 只在 Toolbar 顯示
    UPDATE adempiere.AD_Field
    SET SeqNo = 999, IsDisplayed = 'Y', IsToolbarButton = 'B'
    WHERE AD_Tab_ID = <您的 Tab_ID>
      AND AD_Column_ID = (
        SELECT AD_Column_ID FROM adempiere.AD_Column
        WHERE ColumnName='DocAction'
          AND AD_Table_ID = <您的 Table_ID>
      );
  31. DocStatus 欄位必須設為唯讀 (IsReadOnly='Y'),否則使用者可繞過 Workflow 直接改狀態
    DocStatus 的生命週期(Draft → In Progress → Completed)應由 Document Engine 與 Workflow 節點嚴格控制。若 AD_Field.IsReadOnly 未設為 'Y',使用者可以直接透過下拉選單更改狀態並存檔,完全繞過 prepareIt() 驗證邏輯,導致流程卡死或資料錯亂。

    UPDATE adempiere.AD_Field
    SET IsReadOnly = 'Y'
    WHERE AD_Tab_ID = <您的 Tab_ID>
      AND AD_Column_ID = (
        SELECT AD_Column_ID FROM adempiere.AD_Column
        WHERE ColumnName='DocStatus'
          AND AD_Table_ID = <您的 Table_ID>
      );

表單 UI 排版優化

建好 Table、Window、Tab 並掛載所有 Field 後,iDempiere 預設的呈現是「每個欄位獨佔一整行、照字母排列」。欄位少時勉強接受,但像「人員增補申請」這類欄位眾多的大型單據,預設排版對使用者體驗是災難。以下介紹三個核心排版技能,搭配完整的 SQL 腳本,讓你快速重構出接近標準 Sales Order 風格的專業表單。

核心排版技能一:群組收納(Field Group)

AD_FieldGroup 建立群組分類(例如「需求條件」、「人資處理作業」),再透過 AD_FieldGroup_Trl 補齊中文翻譯,最後將 AD_FieldGroup_ID 寫入目標欄位的 AD_Field.AD_FieldGroup_ID。群組在 UI 上會形成帶標題外框的可收合區塊,大幅提升表單層次感。

核心排版技能二:空間最佳化(IsSameLine & ColumnSpan)

將關聯性強的欄位放同一橫列,可縮短表單垂直長度並引導視覺動線。但有兩個常見踩坑:

  • ColumnSpan Gap 問題:ZK WebUI 網格系統中,標準欄位(含左側標籤 + 右側輸入框)需佔 ColumnSpan = 2。若預設為 1,系統無法正確切分四欄網格,Label 與 Input 之間會產生巨大留白。一般並排欄位設 ColumnSpan=2;全寬文字區塊設 ColumnSpan=5
  • XPosition 強迫折行問題:批次用 SQL 新增 AD_Field 時,絕對不要把所有欄位的 XPosition 都寫死為 1XPosition=1 代表「強制從第一欄開始」,即使設了 IsSameLine='Y',系統也會強迫換行,右側永遠空著。安全做法:不指定 XPosition,或折行欄位設 1、並排欄位設 4(比照 Sales Order 標準)。
  • 高度控制(NumLines):長文字欄位(如職缺描述)若希望在畫面上預設顯示多行高度,記得設定 AD_Field.NumLines=3(或更大)。預設未填通常只有一列高。

核心排版技能三:順序與隱藏(SeqNo & IsDisplayed)

精準控制 AD_Field.SeqNo,讓表單頭部放最重要的識別資訊(單號、類型、狀態),底部放系統欄位。注意:DocActionProcessing 等流程控制欄位可設 IsDisplayed='N'(由 Toolbar 處理),但 DocActionSeqNo 必須大於 0,否則 Toolbar 找不到它,送簽按鈕消失(詳見踩坑 #30)。

實戰範例:Headcount Request 表單三區塊重構 SQL

DO $$
DECLARE
  v_tab_id       NUMERIC;
  v_req_group_id NUMERIC;
  v_hr_group_id  NUMERIC;
BEGIN
  -- 1. 取得目標 Tab ID
  SELECT AD_Tab_ID INTO v_tab_id
  FROM adempiere.AD_Tab
  WHERE Name='Headcount Request' AND AD_Window_ID=1000449;

  -- 2. 建立群組「Requirement(需求條件)」
  IF NOT EXISTS (SELECT 1 FROM adempiere.AD_FieldGroup WHERE Name='Requirement') THEN
    v_req_group_id := nextid('AD_FieldGroup','N');
    INSERT INTO adempiere.AD_FieldGroup
      (AD_FieldGroup_ID, AD_Client_ID, AD_Org_ID, IsActive,
       Created, CreatedBy, Updated, UpdatedBy, Name, EntityType, FieldGroupType)
    VALUES (v_req_group_id, 0, 0, 'Y', now(), 100, now(), 100,
            'Requirement', 'U', 'C');
  ELSE
    SELECT AD_FieldGroup_ID INTO v_req_group_id
    FROM adempiere.AD_FieldGroup WHERE Name='Requirement' LIMIT 1;
  END IF;

  -- 3. 建立群組「HR Processing(人資處理作業)」
  IF NOT EXISTS (SELECT 1 FROM adempiere.AD_FieldGroup WHERE Name='HR Processing') THEN
    v_hr_group_id := nextid('AD_FieldGroup','N');
    INSERT INTO adempiere.AD_FieldGroup
      (AD_FieldGroup_ID, AD_Client_ID, AD_Org_ID, IsActive,
       Created, CreatedBy, Updated, UpdatedBy, Name, EntityType, FieldGroupType)
    VALUES (v_hr_group_id, 0, 0, 'Y', now(), 100, now(), 100,
            'HR Processing', 'U', 'C');
  ELSE
    SELECT AD_FieldGroup_ID INTO v_hr_group_id
    FROM adempiere.AD_FieldGroup WHERE Name='HR Processing' LIMIT 1;
  END IF;

  -- ── 區塊一:基本與單據資訊(置頂,無 FieldGroup)──
  UPDATE adempiere.AD_Field SET SeqNo=10,  IsSameLine='N', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='AD_Org_ID'                AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=20,  IsSameLine='Y', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='DocumentNo'              AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=30,  IsSameLine='N', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='C_DocType_ID'            AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=40,  IsSameLine='Y', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='DocStatus'               AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=50,  IsSameLine='N', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_ApplyDept'            AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=60,  IsSameLine='Y', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='PlannedHireDate'          AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=70,  IsSameLine='N', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_JobTitle'             AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=80,  IsSameLine='Y', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_HeadcountQty'         AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=90,  IsSameLine='N', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_ReasonType'           AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=100, IsSameLine='Y', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_ReplaceEmployeeName'  AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=110, IsSameLine='N', AD_FieldGroup_ID=NULL WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_EmployeeType'         AND AD_Table_ID=1000933);

  -- ── 區塊二:需求條件(掛載 Requirement 群組)──
  UPDATE adempiere.AD_Field SET SeqNo=120, IsSameLine='N', AD_FieldGroup_ID=v_req_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_ExperienceRequired' AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=130, IsSameLine='Y', AD_FieldGroup_ID=v_req_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_EducationLevel'     AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=140, IsSameLine='N', AD_FieldGroup_ID=v_req_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_Major'              AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=150, IsSameLine='Y', AD_FieldGroup_ID=v_req_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_LanguageSkill'      AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=160, IsSameLine='N', AD_FieldGroup_ID=v_req_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_ProfessionalSkill'  AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=170, IsSameLine='N', AD_FieldGroup_ID=v_req_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_OtherCondition'     AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=180, IsSameLine='N', AD_FieldGroup_ID=v_req_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_JobDescription'     AND AD_Table_ID=1000933);

  -- ── 區塊三:人資處理作業(掛載 HR Processing 群組)──
  UPDATE adempiere.AD_Field SET SeqNo=190, IsSameLine='N', AD_FieldGroup_ID=v_hr_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_RecruitMethod'   AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=200, IsSameLine='Y', AD_FieldGroup_ID=v_hr_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_NewEmployeeInfo' AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=210, IsSameLine='N', AD_FieldGroup_ID=v_hr_group_id WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='HR_Comment'         AND AD_Table_ID=1000933);

  -- ── 區塊四:隱藏或置底的系統欄位 ──
  UPDATE adempiere.AD_Field SET SeqNo=900, IsSameLine='N' WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='RejectReason'  AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=910, IsSameLine='Y' WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='AD_Client_ID' AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=920, IsSameLine='Y' WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='IsActive'     AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET SeqNo=930, IsSameLine='Y' WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='Processed'    AND AD_Table_ID=1000933);
  -- DocAction、Processing 交由 Toolbar 控制(SeqNo 必須 > 0,否則 Toolbar 找不到它)
  UPDATE adempiere.AD_Field SET SeqNo=999, IsDisplayed='N' WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='DocAction'   AND AD_Table_ID=1000933);
  UPDATE adempiere.AD_Field SET IsDisplayed='N'            WHERE AD_Tab_ID=v_tab_id AND AD_Column_ID=(SELECT AD_Column_ID FROM adempiere.AD_Column WHERE ColumnName='Processing'  AND AD_Table_ID=1000933);

  -- ── 補齊 FieldGroup 中文翻譯 ──
  INSERT INTO adempiere.AD_FieldGroup_Trl
    (AD_FieldGroup_ID, AD_Language, AD_Client_ID, AD_Org_ID, IsActive,
     Created, CreatedBy, Updated, UpdatedBy, Name, IsTranslated)
  VALUES (v_req_group_id, 'zh_TW', 0, 0, 'Y', now(), 100, now(), 100, '需求條件', 'Y')
  ON CONFLICT (AD_FieldGroup_ID, AD_Language) DO UPDATE SET Name='需求條件';

  INSERT INTO adempiere.AD_FieldGroup_Trl
    (AD_FieldGroup_ID, AD_Language, AD_Client_ID, AD_Org_ID, IsActive,
     Created, CreatedBy, Updated, UpdatedBy, Name, IsTranslated)
  VALUES (v_hr_group_id, 'zh_TW', 0, 0, 'Y', now(), 100, now(), 100, '人資處理作業', 'Y')
  ON CONFLICT (AD_FieldGroup_ID, AD_Language) DO UPDATE SET Name='人資處理作業';

END $$;

下一步

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

  • ✅ 在 iDempiere UI 確認導覽列出現「人員增補申請」選單(已完成)
  • Synchronize Terminology:執行後系統自動同步 AD_Element_Trl 翻譯至 AD_Column_Trl/AD_Field_Trl(詳見踩坑 #21)
  • ✅ 補齊 Document 必備欄位:C_DocType_IDProcessing(詳見第七關)
  • ✅ 建立 C_DocType 文件類型並關聯此 Workflow(詳見第八關)
  • ✅ 設定各審批節點的 AD_WF_Responsible(詳見第九關)
  • ✅ 實作 MHeadcountRequest.java Model class(詳見第十關)

結:雷公曰

一張人員增補表,走過簽查、取名、授權、骨架、審批,終成一條有名有分的 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_Column30 columns (7 system + UUID (_UU) + 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, 8 system-required columns must be inserted first (7 ORM columns + UUID). These are the foundation — 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
HR_HeadcountRequest_UU10UUID (FieldLength=36, naming convention: TableName_UU)

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 C_Order 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.

Gate 7: Add the Document Engine’s Required Columns

The approval chain is wired up, but this form is not yet a true iDempiere “Document.” The Document Engine requires two more columns in the physical table: C_DocType_ID (document type) and Processing (the button that triggers the engine). Phase 1 already covered DocStatus, DocAction, Processed, and DocumentNo — but without C_DocType_ID the engine cannot look up which Workflow to invoke, and without Processing there is no button to trigger it.

Fix in two steps: add the physical columns to the table, then register them in AD_Column and AD_Field. See the Chinese section for the full SQL. Key points: C_DocType_ID uses AD_Reference_ID=19 (Table Direct pointing to C_DocType); Processing uses AD_Reference_ID=28 (Button).

Gate 8: Create C_DocType and Configure the Workflow Table Binding

The document type (C_DocType) determines the document number format and print layout. Important: standard iDempiere’s C_DocType has no Workflow field — the Workflow is not bound via C_DocType. Instead, the binding is configured on the AD_Workflow record itself, by setting AD_Table_ID to point to the target table and WorkflowType='D'. When the Document Engine processes a record, it looks for an AD_Workflow where AD_Table_ID matches, WorkflowType='D', and IsDefault='Y'.

Steps: (1) add a custom DocBaseType value (HRC) to the Reference list; (2) create a Document Sequence for the numbering pattern (e.g. HC-000001); (3) create the C_DocType record with Name = Headcount Request, DocBaseType = HRC, and the HC Sequence — no Workflow field here. Set C_DocType_ID‘s DefaultValue in AD_Column so new records auto-populate it. (4) Update the AD_Workflow record to set WorkflowType='D', AD_Table_ID pointing to HR_HeadcountRequest, and IsDefault='Y' — this is the actual binding the Document Engine uses.

Step 5 — Wire the DocAction button to AD_Process (completing the trilogy): The DocAction column is a Button reference, but it does not directly trigger the Workflow. iDempiere uses an AD_Process as the bridge. Without this link, clicking the Document Action button does nothing. The three-step trilogy: (1) ✅ AD_Workflow already has AD_Table_ID set (Step 4); (2) in Report & Process (System Admin → General Rules → Process), create a dedicated Process named Process HR_HeadcountRequest and set its Workflow field to HR_HeadcountRequest; (3) in Table & Column, find the DocAction column of HR_HeadcountRequest and set its Process field to the Process just created. The SQL shortcut:

UPDATE adempiere.AD_Column
SET AD_Process_ID = (SELECT AD_Process_ID FROM adempiere.AD_Process WHERE Value='Process HR_HeadcountRequest')
WHERE ColumnName='DocAction'
  AND AD_Table_ID=(SELECT AD_Table_ID FROM adempiere.AD_Table WHERE TableName='HR_HeadcountRequest');

Gate 9: Assign Approvers (AD_WF_Responsible)

Nodes are built, but each human-intervention node needs a “duty roster” — AD_WF_Responsible — telling the system whose inbox receives the task. Without it, the workflow stalls at the first approval node because there is nobody to notify.

Three ResponsibleType options: H (Human/Invoker) — the person who created the record; R (Role) — any user holding the specified role; O (Organization) — the org’s supervisor. Assignment strategy for this flow: 申請人 node → Type H (Invoker); manager and HR nodes → Type R, each bound to the appropriate GardenWorld role. Configure in System Admin → General Rules → Workflow → Workflow Responsible, then link each Responsible to its node in the Workflow editor.

Gate 10: Implement MHeadcountRequest.java

The infrastructure is complete. The final piece is the Model Class — without it, clicking Complete triggers nothing because iDempiere has no Java object to call. The Model Class bridges the UI action to actual business logic.

Workflow: run GenerateModel on HR_HeadcountRequest to produce the base class X_HR_HeadcountRequest.java, then extend it with MHeadcountRequest.java. Implement three core methods: customizeValidActions() — controls which actions are available in each document state (DR, IP, CO); prepareIt() — validates required fields before submission and returns STATUS_Invalid on failure; processIt() — delegates to DocumentEngine.processIt(this, action) and lets the iDempiere core drive the workflow forward. Finally, register the class in MyModelFactory.java so iDempiere instantiates the correct model when loading HR_HeadcountRequest records.

⚠️ 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 C_Order. (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)
  19. AD_Workflow must have AD_Table_ID set to locate the form ⭐WorkflowType='D' and IsDefault='Y' are both required, but AD_Table_ID is the most commonly forgotten. Without it the Document Engine cannot find the Workflow at all. (See Gate 8 Step 4)
  20. DocAction (Button) requires an AD_Process bridge to trigger the Workflow ⭐ — setting up the Workflow alone is not enough. The button does not auto-fire. You must create an AD_Process pointing to the Workflow, then bind that Process back to the DocAction AD_Column. Skip this and the button will never respond. (See Gate 8 Step 5)
  21. Skipping Synchronize Terminology leaves all UI labels in English ⭐
    Inserting AD_Column/AD_Field records via SQL does not automatically create the corresponding _Trl translation rows. Even if AD_Element has Chinese names defined, the UI shows English labels when logged in under a Chinese locale. Fix: ensure AD_Element_Trl has zh_TW entries for each element, then run Synchronize Terminology as System Administrator — the process automatically propagates translations from AD_Element_Trl into AD_Column_Trl and AD_Field_Trl.
  22. The UUID column is a required system column — omitting it causes silent save failures ⭐
    Every iDempiere table needs a UUID column named TableName_UU (in this example: HR_HeadcountRequest_UU), with AD_Reference_ID=10 and FieldLength=36, registered in AD_Column. If it is missing, saving a record produces no error message, but nothing is written to the database.
  23. After manually inserting an AD_Table row, forgetting to add a sequence in AD_Sequence causes SaveError (No NextID)
    When you insert a row directly into AD_Table via SQL, you must also create a matching sequence entry in AD_Sequence. Without it, saving a new record throws GenericPO.saveNew: No NextID (-1).
  24. Hard-coded Table_ID in a hand-written X_ class causes ColumnNotFound
    The ModelGenerator bakes the current Table_ID value (e.g., 1000000) into the generated X_ class. In a different database environment that ID may map to a different table, producing errors like ColumnNotFound - SK_Unit.DocStatus. Use MTable.getTable_ID(Table_Name) to look up the ID dynamically, or verify the ID matches after deployment.
  25. Toolbar does not show the Document Action button — WindowType must be T (Transaction)
    After configuring DocAction, AD_Process, and everything else, if the Document Action (gear) button is still absent from the Toolbar, the cause is almost always an incorrect AD_Window.WindowType. SQL-written windows default to M (Maintain). Any window that uses the Document Engine and Workflow must be set to T (Transaction) or the button will never appear.

    UPDATE adempiere.AD_Window SET WindowType = 'T' WHERE name = 'Headcount Request';
  26. Toolbar does not show the Document Action button — missing AD_Process_Access role permission
    Even with WindowType='T' correct, the button may still be hidden because the AD_Process bound to DocAction has no role permissions. iDempiere silently hides the Toolbar button if the current role does not have an entry in AD_Process_Access for that process. SQL-created processes have no role entries by default. Fix via System Admin → Role → Process Access, or run:

    INSERT INTO adempiere.AD_Process_Access (AD_Process_ID, AD_Role_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, IsReadWrite)
    VALUES ((SELECT AD_Process_ID FROM adempiere.AD_Process WHERE Value='Process HR_HeadcountRequest'), 102, 11, 0, 'Y', now(), 100, now(), 100, 'Y');
  27. Toolbar does not show the Document Action button — missing AD_Document_Action_Access entries ⭐
    Even with WindowType='T', a working AD_Process, and correct AD_Process_Access, if the environment enforces document action access control the button will still be hidden. iDempiere checks whether each Role is permitted to perform specific DocAction values on a given C_DocType. A manually created C_DocType has no entries in AD_Document_Action_Access by default. Populate it for the target DocType and Role:

    INSERT INTO adempiere.AD_Document_Action_Access (
       AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
       C_DocType_ID, AD_Role_ID, AD_Ref_List_ID, AD_Document_Action_Access_UU
    )
    SELECT 11, 0, 'Y', now(), 100, now(), 100,
       (SELECT C_DocType_ID FROM adempiere.C_DocType WHERE Name='Headcount Request' LIMIT 1),
       r.ad_role_id, l.ad_ref_list_id, gen_random_uuid()::text
    FROM adempiere.AD_Ref_List l
    CROSS JOIN (SELECT 102 AS ad_role_id UNION SELECT 0) r
    WHERE l.ad_reference_id = 135 AND l.isactive='Y';
  28. Workflow throws Invalid Action (Not Implemented) =B and approval nodes skip the inbox
    When writing AD_WF_Node rows via SQL, the Action code is easy to get wrong in two ways:

    • Invalid Action =B: Submitting a document raises this error because the (Start) node was given a non-existent action code. The correct code for an entry-point or wait node is Z (Wait/Sleep).
    • Approval nodes auto-complete without pausing: If a “human approval” node (e.g., manager, department head) has Action='D' (Document Action), the Workflow engine treats it as an automatic system step and never sends the document to the approver’s inbox to wait for a manual decision.

    Fix: Set human-approval nodes to Action='C' (User Choice) with IsUserApproval='Y'; set the (Start) node to Action='Z'.

    -- Fix human-approval node actions
    UPDATE adempiere.AD_WF_Node
    SET Action = 'C', IsUserApproval = 'Y'
    WHERE AD_Workflow_ID = (SELECT AD_Workflow_ID FROM adempiere.AD_Workflow WHERE Value='HR_HeadcountRequest')
      AND Name IN ('申請人','申請人主管','部門主管','總經理','HR主管');
    
    -- Fix Start node
    UPDATE adempiere.AD_WF_Node
    SET Action = 'Z'
    WHERE AD_Workflow_ID = (SELECT AD_Workflow_ID FROM adempiere.AD_Workflow WHERE Value='HR_HeadcountRequest')
      AND Name = '(Start)';
  29. Hand-written Model class missing imports causes compile failure (cannot find symbol)
    When manually writing MHeadcountRequest.java and calling the document validation event inside prepareIt() or completeIt(), it is easy to forget the required imports, causing the ant build to fail with cannot find symbol.

    import org.compiere.model.ModelValidationEngine;
    import org.compiere.model.ModelValidator;

    No other workaround — just add the missing imports.

  30. DocAction submit button mysteriously disappears — SeqNo=0 or IsDisplayed='N' hides it from the Toolbar ⭐
    It is common practice to hide system fields (Created, Updated, etc.) by setting IsDisplayed='N' and SeqNo=0. But never do this to DocAction. ZK WebUI only instantiates AD_Field rows where SeqNo > 0 and IsDisplayed='Y' when rendering a GridTab. If DocAction is excluded, the Toolbar cannot find it and the submit (gear) button simply vanishes.
    The correct approach — matching the standard Sales Order form — is to give DocAction a very large SeqNo (e.g., 999) and set IsToolbarButton as needed:

    -- 'B' = show in both Toolbar AND the form body (Sales Order style)
    -- 'Y' = show in Toolbar only
    UPDATE adempiere.AD_Field
    SET SeqNo = 999, IsDisplayed = 'Y', IsToolbarButton = 'B'
    WHERE AD_Tab_ID = <your Tab_ID>
      AND AD_Column_ID = (
        SELECT AD_Column_ID FROM adempiere.AD_Column
        WHERE ColumnName='DocAction'
          AND AD_Table_ID = <your Table_ID>
      );
  31. DocStatus must be read-only (IsReadOnly='Y'), otherwise users can bypass the Workflow by changing the status directly
    The DocStatus lifecycle (Draft → In Progress → Completed) must be controlled exclusively by the Document Engine and Workflow nodes. If AD_Field.IsReadOnly is not set to 'Y', users can open the dropdown and save a changed status directly, completely bypassing the prepareIt() validation logic and potentially jamming the workflow or corrupting data.

    UPDATE adempiere.AD_Field
    SET IsReadOnly = 'Y'
    WHERE AD_Tab_ID = <your Tab_ID>
      AND AD_Column_ID = (
        SELECT AD_Column_ID FROM adempiere.AD_Column
        WHERE ColumnName='DocStatus'
          AND AD_Table_ID = <your Table_ID>
      );

Form UI Layout Optimization

After wiring up Table, Window, Tab, and all Fields, iDempiere’s default rendering puts every field on its own full-width row in alphabetical order. That is acceptable for simple forms, but it is a UX disaster for a multi-field document like Headcount Request. The three core layout skills below — plus a complete SQL script — let you quickly restructure the form to match the professional style of the standard Sales Order.

Skill 1: Field Groups (Collapsible Sections)

Create group categories in AD_FieldGroup (e.g., “Requirement”, “HR Processing”), add Chinese translations via AD_FieldGroup_Trl, then write the AD_FieldGroup_ID into each target field’s AD_Field.AD_FieldGroup_ID. Each group renders as a titled, collapsible panel in the UI, adding structure and hierarchy to the form.

Skill 2: Side-by-Side Layout (IsSameLine & ColumnSpan)

Placing related fields on the same horizontal row shortens the form and guides the visual flow. Two common pitfalls:

  • ColumnSpan Gap problem: In the ZK WebUI grid, a standard field (label + input) requires ColumnSpan = 2. Defaulting to 1 breaks the four-column grid and causes a massive gap between the label and the input box. Use ColumnSpan=2 for paired fields, ColumnSpan=5 for full-width text areas.
  • XPosition forced line-break problem: Never hard-code XPosition=1 for all fields in a batch SQL insert. XPosition=1 means “force-start at column 1”, overriding IsSameLine='Y' and always pushing the field to a new row. Safe approach: omit XPosition (let the system compute it), or set line-start fields to 1 and right-side fields to 4 (matching Sales Order standards).
  • Height control (NumLines): For long-text fields like Job Description, set AD_Field.NumLines=3 (or higher) to give the field visible multi-line height. The default of 0 or 1 renders as a single-line input.

Skill 3: Sequence and Visibility (SeqNo & IsDisplayed)

Control AD_Field.SeqNo precisely: put key identification fields (document number, type, status) at the top; push system-maintenance fields to SeqNo 900+. Workflow control fields like DocAction and Processing can be set IsDisplayed='N' so they do not clutter the form — but DocAction‘s SeqNo must remain greater than 0, otherwise the Toolbar cannot find it and the submit button disappears (see Gotcha #30).

Reference SQL: Three-Block Headcount Request Form Restructure

The SQL script in the Chinese section above (same DO $$ block) applies to this form. It organizes fields into three blocks — header info, Requirement group, HR Processing group — and moves system fields to the bottom. Adapt the Table_ID (1000933) and Window_ID (1000449) for your own environment.

What’s Next

  • ✅ Verify the “Headcount Request” menu item appears in the iDempiere navigation bar (completed)
  • Synchronize Terminology: run Synchronize Terminology as System Administrator to propagate AD_Element_Trl translations into AD_Column_Trl/AD_Field_Trl (see Gotcha #21)
  • ✅ Add Document Engine required columns: C_DocType_ID and Processing (see Gate 7)
  • ✅ Create C_DocType and bind Workflow (see Gate 8)
  • ✅ Configure AD_WF_Responsible for each approval node (see Gate 9)
  • ✅ Implement MHeadcountRequest.java Model class (see Gate 10)

雷公曰

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_Column30カラム(システム7カラム + UUID(_UU)+ 主キー + 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。

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

ColumnNameAD_Reference_ID用途
AD_Client_ID19テナント
AD_Org_ID19組織
IsActive20有効フラグ
Created16作成日時
CreatedBy18作成者
Updated16更新日時
UpdatedBy18更新者
HR_HeadcountRequest_UU10UUID(FieldLength=36,命名規則:TableName_UU

これらのカラムに対応する 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 は誤りです。設定前に C_Order の値を確認することを推奨します。また、すべての 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 が実行します。各ノードには二つのトランジションが必要です:承認→次のゲート、却下→申請者に差し戻し。

第七関:Document Engine に必要なカラムを補完する

承認チェーンは完成しましたが、このフォームはまだ iDempiere の「文書」ではありません。Document Engine がフォームを制御するには、物理テーブルに C_DocType_ID(文書タイプ)と Processing(エンジンを起動するボタン)が必要です。Phase 1 では DocStatusDocActionProcessedDocumentNo を追加しましたが、この二つは見落としがちです。C_DocType_ID がないとエンジンはどの Workflow を呼ぶか分からず、Processing がないとボタンが動作しません。

修正は二段階:物理テーブルにカラムを追加し、AD_ColumnAD_Field に登録する。C_DocType_IDAD_Reference_ID=19(Table Direct)、ProcessingAD_Reference_ID=28(Button)を使います。

第八関:C_DocType の作成と AD_Workflow でのテーブルバインド設定

文書タイプ(C_DocType)は単番形式と印刷レイアウトを決定します。重要な注意点:標準の iDempiere では C_DocType に Workflow フィールドは存在しません。Workflow のバインドは C_DocType ではなく、AD_Workflow レコード自体に AD_Table_IDWorkflowType='D' を設定することで行います。Document Engine は処理時に、AD_Table_ID が一致し WorkflowType='D' かつ IsDefault='Y'AD_Workflow レコードを検索してワークフローを起動します。

手順:(1) Reference リストに DocBaseTypeHRC を追加する;(2) Document Sequence を作成する(例:HC-000001);(3) C_DocType レコードを作成し、DocBaseType = HRC、Sequence を設定する(Workflow フィールドはなし)。AD_Column.DefaultValue に DocType ID を設定して新規レコードが自動補完されるようにする。(4) AD_Workflow レコードを更新して WorkflowType='D'AD_Table_IDHR_HeadcountRequest を指定)、IsDefault='Y' を設定する——これが Document Engine が実際に使用するバインドです。

Step 5 — DocAction ボタンと AD_Process の紐付け(三部曲の締め): DocAction カラムは Button 型ですが、Workflow を直接起動することはできません。iDempiere は AD_Process をブリッジとして使用します。この紐付けがないと、Document Action ボタンをクリックしても何も起きません。三部曲の手順:(1) ✅ AD_WorkflowAD_Table_ID は設定済み(Step 4);(2) Report & Process(System Admin → General Rules → Process)で専用の Process を作成し、Name/Value を Process HR_HeadcountRequestWorkflow フィールドに HR_HeadcountRequest を指定する;(3) Table & ColumnHR_HeadcountRequestDocAction カラムを開き、Process フィールドに先ほど作成した Process を設定する。SQL での設定:

UPDATE adempiere.AD_Column
SET AD_Process_ID = (SELECT AD_Process_ID FROM adempiere.AD_Process WHERE Value='Process HR_HeadcountRequest')
WHERE ColumnName='DocAction'
  AND AD_Table_ID=(SELECT AD_Table_ID FROM adempiere.AD_Table WHERE TableName='HR_HeadcountRequest');

第九関:承認担当者の指定(AD_WF_Responsible)

ノードは構築されていますが、人が介入する各ノードには「当番表」—— AD_WF_Responsible ——が必要です。これがないと、ワークフローは最初の承認ノードで止まり、誰にも通知されません。

ResponsibleType は三種類:H(Human/Invoker)——レコードを作成した人;R(Role)——指定ロールを持つ任意のユーザー;O(Organization)——組織の上長。このフローの割り当て方針:申請者 ノードは Type H(Invoker)、管理職と HR ノードは Type R(各 GardenWorld ロールにバインド)。System Admin → General Rules → Workflow → Workflow Responsible で設定し、各 Responsible をワークフローエディタで対応ノードにリンクします。

第十関:MHeadcountRequest.java の実装

インフラは完成しました。最後のピースは Model Class です。これがないと、Complete をクリックしても iDempiere には呼び出す Java オブジェクトがなく、何も起きません。Model Class は UI のアクションと実際のビジネスロジックをつなぐ橋です。

手順:GenerateModel で HR_HeadcountRequest から基底クラス X_HR_HeadcountRequest.java を生成し、それを継承して MHeadcountRequest.java を作成します。三つのコアメソッドを実装:customizeValidActions()——各文書ステータス(DR、IP、CO)で使用可能なアクションを制御;prepareIt()——提出前に必須フィールドを検証し、失敗時は STATUS_Invalid を返す;processIt()——DocumentEngine.processIt(this, action) に委譲して iDempiere コアにワークフロー推進を任せる。最後に MyModelFactory.java にクラスを登録し、HR_HeadcountRequest レコード読み込み時に正しいモデルがインスタンス化されるようにします。

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

以下は実際の建置作業中に踏んだ罠です。いずれも 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 は誤り)C_Order で確認を。(第三関を参照)
  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 が必要。(第三関を参照)
  19. AD_WorkflowAD_Table_ID を設定しないとフォームを特定できない ⭐WorkflowType='D'IsDefault='Y' はどちらも必要ですが、最もよく忘れられるのが AD_Table_ID です。設定しないと Document Engine はこの Workflow を全く見つけられません。(第八関 Step 4 を参照)
  20. DocAction(Button)は AD_Process ブリッジなしに Workflow を起動できない ⭐ — Workflow を設定するだけでは不十分です。ボタンは自動的に起動しません。AD_Process を作成してその Workflow を指定し、その Process を DocAction の AD_Column に紐付ける必要があります。この手順を省略するとボタンが永遠に反応しません。(第八関 Step 5 を参照)
  21. Synchronize Terminology を実行しないと UI ラベルが英語のまま ⭐
    SQL で AD_Column/AD_Field を大量 INSERT しても、対応する _Trl レコードは自動生成されません。AD_Element に多言語名を定義していても、多言語インターフェースでは英語ラベルが表示されます。対処法:各 Element の AD_Element_TrlAD_Language='zh_TW' など)を登録してから、System Administrator で Synchronize Terminology を実行——翻訳が AD_Column_TrlAD_Field_Trl に自動同期されます。
  22. Toolbar に Document Action ボタンが表示されない——WindowTypeT(Transaction)でなければならない
    DocActionAD_Process など全ての設定を終えても Toolbar に Document Action(歯車)ボタンが表示されない場合、原因はほぼ確実に AD_Window.WindowType の誤設定です。SQL で手書きした Window はデフォルト M(Maintain)になりがちですが、Document Engine と Workflow を持つ Window は T(Transaction)でなければボタンが表示されません。

    UPDATE adempiere.AD_Window SET WindowType = 'T' WHERE name = 'Headcount Request';
  23. Toolbar に Document Action ボタンが表示されない——AD_Process_Access のロール権限が不足
    WindowType='T' でも Toolbar にボタンが出ない場合、DocAction に紐付けた AD_Process にロール権限が設定されていない可能性があります。iDempiere は現在ログイン中のロールに AD_Process_Access エントリがなければ Toolbar ボタンをサイレントに非表示にします。SQL で作成した AD_Process にはデフォルトで権限エントリがありません。System Admin → Role → Process Access から設定するか、以下の SQL を実行してください:

    INSERT INTO adempiere.AD_Process_Access (AD_Process_ID, AD_Role_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, IsReadWrite)
    VALUES ((SELECT AD_Process_ID FROM adempiere.AD_Process WHERE Value='Process HR_HeadcountRequest'), 102, 11, 0, 'Y', now(), 100, now(), 100, 'Y');
  24. Toolbar に Document Action ボタンが表示されない——AD_Document_Action_Access(単票アクション権限)が未設定 ⭐
    WindowType='T'AD_ProcessAD_Process_Access が全て正しく設定されていても、環境が単票アクション権限管理を有効にしている場合はボタンが非表示になります。iDempiere は各 Role が特定 C_DocType に対してどの DocAction を実行できるかを制御しており、SQL で手動作成した C_DocType はデフォルトで AD_Document_Action_Access にエントリがなく、ボタンが表示されません。対象 DocType と Role に対してレコードを追加してください:

    INSERT INTO adempiere.AD_Document_Action_Access (
       AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy,
       C_DocType_ID, AD_Role_ID, AD_Ref_List_ID, AD_Document_Action_Access_UU
    )
    SELECT 11, 0, 'Y', now(), 100, now(), 100,
       (SELECT C_DocType_ID FROM adempiere.C_DocType WHERE Name='Headcount Request' LIMIT 1),
       r.ad_role_id, l.ad_ref_list_id, gen_random_uuid()::text
    FROM adempiere.AD_Ref_List l
    CROSS JOIN (SELECT 102 AS ad_role_id UNION SELECT 0) r
    WHERE l.ad_reference_id = 135 AND l.isactive='Y';
  25. Workflow が Invalid Action (Not Implemented) =B をスローし、承認ノードが受信トレイに届かない
    SQL で AD_WF_Node を手書きする際、Action コードを間違えやすい落とし穴が二つあります:

    • Invalid Action =B:単票を送出するとこのエラーが発生する場合、(Start) ノードに存在しないコードが設定されています。エントリポイントや待機ノードの正しいコードは Z(Wait/Sleep)です。
    • 承認ノードが自動通過してしまう:「人工承認」ノード(上長・部門長など)の ActionD(Document Action)に誤設定されると、Workflow エンジンがシステム自動処理として扱い、承認者の受信トレイに止まらず自動的に次へ進んでしまいます。

    対処法:人工承認ノードは Action='C'(User Choice)に変更し IsUserApproval='Y' を設定、(Start) ノードは Action='Z' に修正します。

    -- 人工承認ノードの Action を修正
    UPDATE adempiere.AD_WF_Node
    SET Action = 'C', IsUserApproval = 'Y'
    WHERE AD_Workflow_ID = (SELECT AD_Workflow_ID FROM adempiere.AD_Workflow WHERE Value='HR_HeadcountRequest')
      AND Name IN ('申請人','申請人主管','部門主管','總經理','HR主管');
    
    -- Start ノードを修正
    UPDATE adempiere.AD_WF_Node
    SET Action = 'Z'
    WHERE AD_Workflow_ID = (SELECT AD_Workflow_ID FROM adempiere.AD_Workflow WHERE Value='HR_HeadcountRequest')
      AND Name = '(Start)';
  26. 手書き Model クラスで import 忘れによりコンパイルエラー (cannot find symbol)
    MHeadcountRequest.java を手動で作成し、prepareIt() / completeIt() 内で文書検証イベントを呼び出す際、import 文を忘れやすく、ant ビルドが cannot find symbol で失敗します。

    import org.compiere.model.ModelValidationEngine;
    import org.compiere.model.ModelValidator;

    他に回避策はありません。忘れずに import を追加してください。

  27. DocAction 送信ボタンが突然消える——SeqNo=0 または IsDisplayed='N' で Toolbar から見えなくなる ⭐
    システムフィールド(CreatedUpdated など)を IsDisplayed='N'SeqNo=0 で非表示にするのは一般的ですが、DocAction には絶対にしてはいけません。ZK WebUI は GridTab を描画する際、SeqNo > 0 かつ IsDisplayed='Y' の AD_Field のみをインスタンス化します。DocAction が除外されると Toolbar がそれを見つけられず、送信(歯車)ボタンが完全に消えてしまいます。
    正しい対応は標準フォーム(Sales Order など)に倣い、DocAction に大きな SeqNo(例:999)を設定し、IsToolbarButton を適切に設定することです:

    -- 'B' = Toolbar とフォーム本体の両方に表示(Sales Order スタイル)
    -- 'Y' = Toolbar のみに表示
    UPDATE adempiere.AD_Field
    SET SeqNo = 999, IsDisplayed = 'Y', IsToolbarButton = 'B'
    WHERE AD_Tab_ID = <対象の Tab_ID>
      AND AD_Column_ID = (
        SELECT AD_Column_ID FROM adempiere.AD_Column
        WHERE ColumnName='DocAction'
          AND AD_Table_ID = <対象の Table_ID>
      );
  28. DocStatus は読み取り専用(IsReadOnly='Y')にしないと、ユーザーが Workflow をバイパスして状態を直接変更できてしまう
    DocStatus のライフサイクル(Draft → In Progress → Completed)は Document Engine と Workflow ノードが厳密に制御すべきです。AD_Field.IsReadOnly'Y' に設定しないと、ユーザーがドロップダウンから直接ステータスを変更して保存でき、prepareIt() の検証ロジックを完全にバイパスしてワークフローのデッドロックやデータ破損を引き起こす可能性があります。

    UPDATE adempiere.AD_Field
    SET IsReadOnly = 'Y'
    WHERE AD_Tab_ID = <対象の Tab_ID>
      AND AD_Column_ID = (
        SELECT AD_Column_ID FROM adempiere.AD_Column
        WHERE ColumnName='DocStatus'
          AND AD_Table_ID = <対象の Table_ID>
      );

フォーム UI レイアウト最適化

Table・Window・Tab・Field をすべて組み上げた後、iDempiere のデフォルト表示は「全フィールドがアルファベット順に 1 フィールド=1 行」です。フィールドが少なければ許容できますが、人員増補申請のように多数のフィールドを持つ大型単票では UX 上の問題となります。以下の三つの核心スキルと完全な SQL スクリプトにより、標準 Sales Order に近いプロフェッショナルなレイアウトへ迅速に再構成できます。

スキル 1:グループ収納(Field Group)

AD_FieldGroup に分類グループ(例:「Requirement」「HR Processing」)を作成し、AD_FieldGroup_Trl で翻訳を補い、対象フィールドの AD_Field.AD_FieldGroup_ID に書き込みます。UI ではタイトル付きの折り畳み可能なパネルとして表示され、フォームに階層感と構造をもたらします。

スキル 2:横並びレイアウト(IsSameLine & ColumnSpan)

関連性の高いフィールドを同じ水平行に配置するとフォームの垂直長を短縮できますが、よくある落とし穴が二つあります:

  • ColumnSpan Gap 問題:ZK WebUI グリッドでは、標準フィールド(ラベル+入力ボックス)は ColumnSpan = 2 が必要です。デフォルトの 1 では四列グリッドを正しく分割できず、ラベルと入力ボックスの間に大きな余白が生じます。並列フィールドは ColumnSpan=2、全幅テキストエリアは ColumnSpan=5 を使用してください。
  • XPosition による強制改行問題:SQL で AD_Field を一括追加する際、全フィールドの XPosition1 にハードコードしないでください。XPosition=1 は「強制的に第 1 列から開始」を意味し、IsSameLine='Y' を無効化して常に改行させます。安全な方法:XPosition を省略するか、改行フィールドを 1、右側フィールドを 4 に設定します(Sales Order 標準に準拠)。
  • 高さ制御(NumLines):職務内容などの長文フィールドは AD_Field.NumLines=3(以上)を設定しないと、デフォルトの 1 行入力として表示されます。

スキル 3:順序と表示制御(SeqNo & IsDisplayed)

AD_Field.SeqNo を精密に制御して、フォーム上部に重要な識別情報(単票番号・種類・ステータス)を、下部にシステムフィールドを配置します。DocActionProcessing などのワークフロー制御フィールドは IsDisplayed='N' でフォームから非表示にできますが、DocActionSeqNo は必ず 0 より大きくしてください。そうしないと Toolbar がそれを見つけられず、送信ボタンが消えてしまいます(ハマりポイント #30 参照)。

参考 SQL:Headcount Request フォーム 3 ブロック再構成

上記の中国語セクションの DO $$ ブロック(SQL スクリプト)がこのフォームに適用されます。フィールドをヘッダー情報・Requirement グループ・HR Processing グループの三ブロックに整理し、システムフィールドを末尾に移動します。Table_ID(1000933)と Window_ID(1000449)はご自身の環境に合わせて変更してください。

次のステップ

  • ✅ iDempiere のナビゲーションバーに「人員増補申請」メニューが表示されていることを確認(完了)
  • Synchronize Terminology:実行済み——AD_Element_Trl の翻譯が AD_Column_Trl/AD_Field_Trl に自動同期されます(ハマりポイント #21 を参照)
  • ✅ Document Engine 必須カラムの追加:C_DocType_IDProcessing(第七関を参照)
  • C_DocType の作成と Workflow のバインド(第八関を参照)
  • ✅ 各承認ノードへの AD_WF_Responsible の設定(第九関を参照)
  • MHeadcountRequest.java Model Class の実装(第十関を参照)

雷公曰

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

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

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

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