分類: iDempiere 教學

iDempiere 開源 ERP 完整教學系列

  • 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 正常,流程可從選單觸發