iDempiere Plugin 開發完整指南:踩遍台灣統一發票的 10 個坑

重點摘要

  • 我們用兩天為 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/8443Web 瀏覽器連這個
JVM-B(新的)12612OSGi 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_PreferenceAD_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 的文件很少,很多行為只有讀原始碼才能理解。以下是花最多時間搞懂的六件事:

# 規則 說明
1ModelValidator 不是 OSGi 服務@Component 不會讓它跑起來,要用 EventHandler
2SeqNoGrid 是 Grid View 必備欄位缺了不報錯,只在按「新增」時崩潰
32Pack ZIP 結構有嚴格要求打包後一定要 unzip -l 驗證,絕不用 zip -j
4_UU 欄位必須 IsUpdateable=Y否則 UUID 永遠 NULL
5IEventTopics 常數不要用字串字面量比較 topic,靜默失效
6Incremental2PackActivator 記住安裝歷史同版本不重裝,升版要改 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

FieldLengthad_process_para 資料表中是 NOT NULL 欄位,但如果 PackOut.xml 沒有提供,iDempiere 的 log 只會說 Failed to save ProcessPara——沒有欄位名稱,沒有 SQL,什麼都沒有。

不同 Reference 型別的預設長度:

AD_Reference_ID 型別 FieldLength
11Integer10
17List1
10String實際最大長度

教訓: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 的 DB class 管理連線池和事務,不要繞過它。
  • 記得 DB.close(rs, ps):在 finally block 釋放資源。

clean_reinstall.sh — 開發期間的救命工具

開發期間反覆修改 PackOut.xml,每次都要手動清資料庫重裝,非常繁瑣。寫了一個腳本自動化整個流程:

  1. 依 FK 順序刪除所有 TW 字典(Window_Access → Process_Access → Field → Tab → Menu → Window → Process_Para → Process)
  2. Drop 物理表(adempiere.TW_*
  3. 清除後設資料(Column → Table → Sequence → Element → Reference → EntityType)
  4. 清 AD_Package_Imp_Detail / AD_Package_Imp(注意:Detail 要先刪,因為有 FK)
  5. 呼叫 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 正常,流程可從選單觸發

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *