重點摘要
- 我們用兩天為 iDempiere 12.0 從零開發了台灣統一發票 OSGi Plugin,踩了至少 7 個主要的坑
- 最燒時間的 bug:SeqNoGrid 缺失導致 Grid View 在按「新增」時拋出 IndexOutOfBoundsException——官方文件完全沒提
- EventHandler 模式是 iDempiere 12 的正確驗證方式,ModelValidator 不透過 OSGi DS 登錄根本不執行
- 2Pack ZIP 結構、事件主題比較、雙 JVM 部署問題——每一個都能讓你白白浪費半天
這篇文章記錄了我們為 iDempiere 12.0 從零開發台灣統一發票(統一發票)與營業稅申報 Plugin 的完整過程。如果你正在開發 iDempiere Plugin,這篇文章能幫你少掉至少一天的除錯時間。
為什麼要做這個 Plugin?
台灣的統一發票制度獨特而嚴格:每兩個月一期的雙月申報週期、財政部核配的字軌號碼(如 AA01234567)、三聯式(B2B)和二聯式(B2C)的不同計稅方式、以及嚴格的 FLOOR 取捨規定。
這些都是 iDempiere 標準功能完全沒有覆蓋的。既有的 C_Invoice 系統不知道什麼是「字軌」,不知道進項折讓要在哪一期申報,更不知道 401 申報表長什麼樣子。所以我們做了這個 Plugin。
系統架構概覽:四張表對應四個階段
iDempiere Plugin 開發的核心是理清業務模型。台灣統一發票有清楚的生命週期,我們用四張資料表對應:
財政部核配字軌
│
▼
TW_InvoicePrefix ← 字軌管理(AA、AB 等,號碼範圍、有效期)
│ 開立發票時
▼
TW_Invoice_Prefix_Map ← 每張發票的字軌號碼對應(含買方統一編號)
│ 如有退貨/折讓
▼
TW_InvoiceAdjustment ← 銷項/進項折讓(方向、期別、超期申報)
│ 期末
▼
TW_TaxStatement ← 401 申報表(銷項稅、進項稅、留抵、應納稅額)
技術層面:OSGi bundle(Equinox),2Pack 管理 dictionary,AbstractEventHandler 做 PO 事件驗證,服務層純 Java 方便單元測試。最終成品:87 個測試全部通過,4 張 TW_* 資料表,4 個 iDempiere 視窗。
把台灣稅法翻成 Java 程式碼
稅額計算:FLOOR,不是 ROUND
這是財政部的明確規定,一律捨去,不四捨五入。聽起來簡單,但在邊界值差一塊錢,申報出去就是對不上帳:
// 二聯式(B2C):含稅金額已知,反推銷售額和稅額
BigDecimal saleAmount = grossAmount
.divide(new BigDecimal("1.05"), 0, RoundingMode.FLOOR);
BigDecimal taxAmount = saleAmount
.multiply(new BigDecimal("0.05"))
.setScale(0, RoundingMode.FLOOR);
// 注意:taxAmount ≠ grossAmount - saleAmount
// 財政部規定:用前者(先算銷售額,再乘 5%)
字軌狀態機:單向前進
字軌狀態只能往前走,不能回頭:I(未啟用)→ A(使用中)→ C(已用完)。這是台灣稅法要求,已啟用的字軌必須被追蹤,不能撤回。
// EventHandler 在 PO_BEFORE_CHANGE 攔截
if ("A".equals(oldStatus) && "I".equals(newStatus))
throw new AdempiereException("使用中字軌不可降回未啟用(台灣稅法)");
if ("C".equals(oldStatus))
throw new AdempiereException("已用完字軌不可再變更狀態");
雙月申報期別計算
// 從發票月份算期別
int period = (month - 1) / 2 + 1; // 1=1-2月, 2=3-4月 ... 6=11-12月
七個真實踩坑紀錄
坑 1:事件主題比較,一個字毀掉一切
這個 bug 讓狀態驗證完全靜默失效了很久。症狀是:程式碼看起來完全正確,但驗證從來不觸發。
// 錯誤 — 永遠不會觸發
if (topic.endsWith("po_before_change")) { ... }
// 正確
if (IEventTopics.PO_BEFORE_CHANGE.equals(topic)) { ... }
iDempiere 的 topic 格式是 org/adempiere/po/PO_BEFORE_CHANGE(全大寫),endsWith 配對小寫後綴當然不匹配。靜默失效最可怕——不報錯,只是所有驗證都沒執行。規則:事件主題永遠用 IEventTopics 常數。
坑 2:ModelValidator 是個陷阱
Validator 類別一開始實作了完整的 ModelValidator 介面(initialize、modelChange、docValidate、login…),程式碼寫得很整齊,但驗證從來不執行。
原因:ModelValidator 需要透過 ModelValidationEngine.addModelValidator() 主動登錄。單純 implements ModelValidator + @Component 什麼都不會發生。
正確的 iDempiere 12 Plugin 驗證模式:
// *Validator.java → 純靜態方法,不實作任何介面
// *EventHandler.java → extends AbstractEventHandler,OSGI-INF/*.xml 登錄為 DS 服務
// EventHandler 捕捉 OSGi 事件,呼叫 Validator 的靜態方法做驗證
@Component(immediate = true, service = IEventHandler.class)
public class InvoicePrefixEventHandler extends AbstractEventHandler {
@Override
protected void doHandleEvent(Event event) {
String topic = (String) event.getProperty(IEventTopics.EVENT_TOPIC);
if (IEventTopics.PO_BEFORE_CHANGE.equals(topic)) {
String err = InvoicePrefixValidator.validateStatusTransition(...);
if (err != null) throw new AdempiereException(err);
}
}
}
坑 3:2Pack ZIP 打包方式,犯了兩次
iDempiere 的 PackIn 解壓 ZIP 到 /tmp/,預期路徑是 /tmp/tw_invoice_system/dict/PackOut.xml。
第一次:ZIP 裡多了 2pack/ 前綴層。第二次:用了 zip -j(junk paths),把所有目錄剝掉,路徑變成 /tmp/PackOut.xml/dict/PackOut.xml——把文件名當目錄了。
# 正確打包方式
mkdir -p /tmp/b/tw_invoice_system/dict
cp PackOut.xml /tmp/b/tw_invoice_system/dict/
cd /tmp/b && zip -r 2Pack_1.0.10.zip tw_invoice_system/
# 永遠要驗證結構
unzip -l 2Pack_1.0.10.zip
坑 4:SeqNoGrid 缺失 → Grid View 崩潰(最燒時間)
這是整個過程中最「無辜」的 bug——不是邏輯錯誤,是 XML 少了兩個欄位。所有 73 個 AD_Field 元素都缺少 <SeqNoGrid> 和正確的 <IsDisplayedGrid>。
iDempiere 12 的 Grid View 渲染器在初始化行編輯器時需要 SeqNoGrid 做排序依據,全部 NULL 導致:
java.lang.IndexOutOfBoundsException: Index: 2
at GridTabRowRenderer.editCurrentRow
任何視窗,只要在 Grid 模式下按「新增」,必爆。這個 bug 在官方文件裡完全沒有提到。修復:補上這兩個欄位。
<SeqNo>10</SeqNo>
<SeqNoGrid>10</SeqNoGrid> <!-- = SeqNo,顯示欄位 -->
<IsDisplayedGrid>Y</IsDisplayedGrid>
<!-- 隱藏欄位(SeqNo=0)-->
<SeqNoGrid>0</SeqNoGrid>
<IsDisplayedGrid>N</IsDisplayedGrid>
坑 5:兩個 JVM 搶 Port,Deploy 進錯的那個
症狀很奇怪:OSGi telnet console 顯示 bundle ACTIVE ✅,但 Web Console 找不到這個 bundle ❌。
最後發現機器上跑著兩個 iDempiere JVM:
| JVM | 擁有 Port | 說明 |
|---|---|---|
| JVM-A(舊的) | 8080/8443 | Web 瀏覽器連這個 |
| JVM-B(新的) | 12612 | OSGi telnet 連這個 |
deploy.sh 透過 telnet 把 bundle 裝進了 JVM-B,但使用者看的 Web Console 是 JVM-A。原因是 idempiere-server.sh 有 restart loop,systemctl restart 啟動了新 JVM,但舊 JVM 沒死。
# 診斷方法
ps aux | grep java | grep -v grep | wc -l # > 1 就是這個問題
# 修復:kill 兩個 JVM,等 30 秒讓 port 釋放,再 restart
sudo kill <pid1> <pid2>
sleep 30
sudo systemctl restart idempiere
坑 6:AD_Field UUID 衝突
2Pack 重裝時,iDempiere 已為新建的 Tab 自動插入標準欄位(AD_Client_ID、AD_Org_ID、IsActive)。當 PackIn 再次嘗試用不同 UUID 插入同一個 (tab_id, column_id) 組合時,違反了 UNIQUE(ad_tab_id, ad_column_id) 約束,拋出 POSaveFailedException。
解法:對這類「系統可能已存在」的標準欄位,PackOut.xml 使用固定的 placeholder UUID。PackIn 遇到重複就 UPDATE 而不是 INSERT。升版 UUID 策略:已存在的 field → 保留原 UUID;新增的 field → 才用新的 uuid4。不要為了「整齊」換掉既有 UUID。
坑 7:清除 DB 做全新安裝——FK 刪除順序
當要做「完整卸載 → 清 DB → 重裝」的驗證時,刪資料的順序錯了好幾次。
正確順序:AD_Field → AD_Tab → AD_Window,每一步都有 FK 指向下一個。特別注意:AD_Preference 和 AD_Menu 也會 FK 到 AD_Window,忘記刪這兩個就卡住。另外:ad_package_imp 記錄了 2Pack 安裝歷史,不清掉的話 Incremental2PackActivator 判定「已安裝過同版本」就跳過不執行。
如果重來一次,我們會怎麼做
1. 先建立 PackOut.xml 驗證腳本
每次 2Pack 安裝後自動跑這些 SQL,出問題立刻知道,不用等到 UI 爆炸:
-- SeqNoGrid 是否設定
SELECT tablename, count(*) FILTER (WHERE seqnogrid > 0) ok
FROM ad_table JOIN ad_tab ... JOIN ad_field ...
WHERE tablename LIKE 'TW_%' GROUP BY tablename;
-- _UU 欄位是否 updateable
SELECT columnname, isupdateable FROM ad_column
WHERE tablename LIKE 'TW_%' AND columnname LIKE '%_UU';
2. 把 ZIP 結構驗證寫進 Maven build
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<executions>
<execution>
<phase>verify</phase>
<goals><goal>exec</goal></goals>
<configuration>
<executable>bash</executable>
<arguments>
<argument>-c</argument>
<argument>unzip -l resources/META-INF/2Pack_*.zip | grep -q "dict/PackOut.xml"</argument>
</arguments>
</configuration>
</execution>
</executions>
</plugin>
3. 每次 commit 前跑 deploy.sh –check
加一個 dry-run 模式,只驗證 JAR 可部署、OSGi console 可連線、bundle 存在,不實際更新。讓這個檢查成為 commit hook。
給下一個要做 iDempiere Plugin 的人
iDempiere 的文件很少,很多行為只有讀原始碼才能理解。以下是花最多時間搞懂的六件事:
| # | 規則 | 說明 |
|---|---|---|
| 1 | ModelValidator 不是 OSGi 服務 | @Component 不會讓它跑起來,要用 EventHandler |
| 2 | SeqNoGrid 是 Grid View 必備欄位 | 缺了不報錯,只在按「新增」時崩潰 |
| 3 | 2Pack ZIP 結構有嚴格要求 | 打包後一定要 unzip -l 驗證,絕不用 zip -j |
| 4 | _UU 欄位必須 IsUpdateable=Y | 否則 UUID 永遠 NULL |
| 5 | IEventTopics 常數 | 不要用字串字面量比較 topic,靜默失效 |
| 6 | Incremental2PackActivator 記住安裝歷史 | 同版本不重裝,升版要改 ZIP 檔名 |
後記:實作兩個流程(又踩了三個坑)
文章發完當天,隨即著手實作原本標注為「計劃中功能」的兩支 SvrProcess:
- GenerateTaxStatementProcess — 聚合 TW_Invoice_Prefix_Map 的銷售資料、折讓,產生 TW_TaxStatement 申報記錄
- ExportTaxReportProcess — 讀取 TW_TaxStatement,輸出財政部格式 CSV
同時也需要把這兩個流程透過 2Pack 登錄到 iDempiere 的 AD_Process / AD_Menu,讓使用者可以從選單觸發。這一輪又多踩了幾個坑。
坑 8:AD_Process_Para 的 AD_Process_ID 必須明確寫入
2Pack 的元素處理器(ElementHandler)有一個隱藏的不一致性。
寫 AD_Tab 時,把它放在 <AD_Window> 元素內,Tab 的 AD_Window_ID 會自動繼承父元素——不需要重複宣告。大多數巢狀元素都這樣工作。
但 AD_Process_Para 不同。ProcessParaElementHandler 呼叫 filler.autoFill() 處理子元素,但不從父 <AD_Process> 讀取 context 填入 AD_Process_ID。
結果:2Pack 安裝過程看起來正常執行,沒有任何錯誤訊息,但 Process Para 根本沒有存入。直到手動查 DB 才發現空的。真正的錯誤需要翻 PostgreSQL log:
tail -f /var/log/postgresql/postgresql-16-main.log
# ERROR: null value in column "ad_process_id" violates not-null constraint
修正:每個 <AD_Process_Para> 元素內都必須顯式宣告 AD_Process_ID:
<AD_Process_Para type="table">
<AD_Process_ID reference="uuid" reference-key="AD_Process">{process-uuid}</AD_Process_ID>
<Name>Statement Year</Name>
...
</AD_Process_Para>
坑 9:FieldLength NOT NULL(但 iDempiere log 不說)
修好 AD_Process_ID 之後,再次安裝,再次失敗,再次查 PostgreSQL log:
ERROR: null value in column "fieldlength" violates not-null constraint
FieldLength 在 ad_process_para 資料表中是 NOT NULL 欄位,但如果 PackOut.xml 沒有提供,iDempiere 的 log 只會說 Failed to save ProcessPara——沒有欄位名稱,沒有 SQL,什麼都沒有。
不同 Reference 型別的預設長度:
| AD_Reference_ID | 型別 | FieldLength |
|---|---|---|
| 11 | Integer | 10 |
| 17 | List | 1 |
| 10 | String | 實際最大長度 |
教訓:2Pack 的錯誤訊息有時候刻意模糊。遇到 Failed to save 之類的通用錯誤,直接去查 PostgreSQL log,那裡才有真相。
坑 10:iDempiere 物理表在 adempiere schema,不在 public
寫完 clean_reinstall.sh(一個用來清除所有 TW_* 字典並重新部署的腳本)後,發現 DROP TABLE 完全沒作用:
DROP TABLE IF EXISTS TW_InvoicePrefix; -- 執行成功,但表還在
原來 iDempiere 12.0 把物理表建在 adempiere schema,而不是 public。用 information_schema.tables WHERE table_schema='public' 查詢,一張 TW_* 表都找不到。
正確用法:
-- 確認表存在
SELECT tablename FROM pg_tables WHERE schemaname='adempiere' AND tablename ILIKE 'tw_%';
-- 刪除表
DROP TABLE IF EXISTS adempiere.TW_InvoicePrefix;
DROP TABLE IF EXISTS adempiere.TW_Invoice_Prefix_Map;
這也影響 psql 互動查詢——連線後預設 search_path 如果不含 adempiere,直接 SELECT * FROM TW_InvoicePrefix 會找不到表。
SvrProcess 實作要點
SvrProcess 的標準結構很直覺——prepare() 讀參數、doIt() 做事。幾個需要注意的地方:
- 不要呼叫
ps.setAD_Client_ID():那是 PO 類別的 protected 方法,不是PreparedStatement的方法。在 SvrProcess 裡用getAD_Client_ID()取值,直接ps.setInt(1, getAD_Client_ID())。 - 用
DB.prepareStatement()而不是 JDBC 直接連線:iDempiere 的DBclass 管理連線池和事務,不要繞過它。 - 記得
DB.close(rs, ps):在 finally block 釋放資源。
clean_reinstall.sh — 開發期間的救命工具
開發期間反覆修改 PackOut.xml,每次都要手動清資料庫重裝,非常繁瑣。寫了一個腳本自動化整個流程:
- 依 FK 順序刪除所有 TW 字典(Window_Access → Process_Access → Field → Tab → Menu → Window → Process_Para → Process)
- Drop 物理表(
adempiere.TW_*) - 清除後設資料(Column → Table → Sequence → Element → Reference → EntityType)
- 清 AD_Package_Imp_Detail / AD_Package_Imp(注意:Detail 要先刪,因為有 FK)
- 呼叫 deploy.sh 重新部署
FK 刪除順序是最麻煩的部分——錯一個順序就會碰到 FK 違規,整個腳本失敗。從失敗中整理出來的正確順序如上。
最終成果(v1.0.11)
Bundle: tw.idempiere.invoice.tax v1.0.0 (2Pack v1.0.11)
Tests: 89 個,全數通過
Tables: 4 張 TW_* 資料表
Windows: 4 個 iDempiere 視窗
Processes: 2 個(GenerateTaxStatement + ExportTaxReport)
Fields: 73 個 AD_Field(含正確 SeqNoGrid)
Menu: 7 個項目(1 父選單 + 4 視窗 + 2 流程)
Status: ACTIVE,Grid View 正常,流程可從選單觸發