API 整合

iDempiere REST API 概述

iDempiere Mobile 透過 idempiere-rest 外掛的 OData REST API 與 iDempiere ERP 伺服器通訊。所有資料存取都使用標準的 HTTP 方法操作 REST 端點。

┌─────────────────────┐       HTTPS/JWT        ┌──────────────────────┐
│   Flutter App        │ <──────────────────►   │  iDempiere ERP       │
│   (iOS/Android/Web/  │   idempiere-rest API   │  + idempiere-rest    │
│    macOS)            │   /api/v1/...          │    plugin            │
└─────────────────────┘                         └──────────────────────┘

認證流程(兩步驟)

認證分為兩個步驟:先登入取得臨時 token 和可用角色列表,再選擇角色取得正式 session token。

步驟 1:登入

POST /api/v1/auth/tokens
Content-Type: application/json

{
  "userName": "your-username",
  "password": "your-password"
}

# 回應:
{
  "token": "jwt-temporary-token...",
  "clients": [
    {
      "id": 11,
      "name": "GardenWorld",
      "roles": [
        { "id": 102, "name": "GardenAdmin" }
      ]
    }
  ]
}

步驟 2:選擇角色

PUT /api/v1/auth/tokens
Authorization: Bearer jwt-temporary-token
Content-Type: application/json

{
  "clientId": 11,
  "roleId": 102,
  "organizationId": 11,
  "language": "zh_TW"
}

# 回應:
{
  "token": "jwt-session-token..."
}
  • 步驟 1 回傳臨時 token 和可用的 client/role 列表
  • 步驟 2 選擇工作的 client/role/org 並回傳正式 session token
  • Token 儲存在 FlutterSecureStorage(iOS Keychain / Android EncryptedSharedPreferences)
  • 後續所有請求由 AuthInterceptor 自動附加 Authorization: Bearer <token>

一步登入(已知所有 ID)

若已知 clientId、roleId、organizationId、warehouseId,可在一次 POST 中完成登入:

POST /api/v1/auth/tokens
{
  "userName": "X", "password": "Y",
  "parameters": {
    "clientId": 11, "roleId": 102,
    "organizationId": 11, "warehouseId": 103, "language": "zh_TW"
  }
}
// 回應:{ "token": "eyJ...", "refresh_token": "eyJ..." }

中間查詢端點

步驟 1 與步驟 2 之間,可查詢可用的角色、組織、倉庫:

GET /api/v1/auth/roles?client={clientId}
GET /api/v1/auth/organizations?client={clientId}&role={roleId}
GET /api/v1/auth/warehouses?client={clientId}&role={roleId}&organization={orgId}

Token 管理

項目 說明
Token 有效期 預設 1 小時
Refresh Token 24 小時,只能用一次,重複使用觸發安全封鎖
刷新 POST /api/v1/auth/refresh + refresh_token
登出 POST /api/v1/auth/logout
角色限制 只有 Role Type = WebService 或空白的角色可 API 登入
PUT 換 Token ⚠️ 回傳新 token,舊 token 立即失效

CRUD 操作

所有資料存取使用 GET/POST/PUT/DELETE 操作 /api/v1/models/{tableName}

HTTP 方法 端點 用途
GET /api/v1/models/{tableName} 查詢記錄列表
GET /api/v1/models/{tableName}/{id} 取得單筆記錄
POST /api/v1/models/{tableName} 新增記錄
PUT /api/v1/models/{tableName}/{id} 更新記錄
DELETE /api/v1/models/{tableName}/{id} 刪除記錄

Dart 程式碼範例

// ApiClient 封裝了所有 CRUD 操作
final api = ref.watch(apiClientProvider);

// 查詢列表(含篩選、排序、展開外鍵)
final data = await api.getRecords('C_Order',
  filter: "IsSOTrx eq true and DocStatus eq 'DR'",
  orderBy: 'DateOrdered desc',
  expand: 'C_BPartner_ID,C_DocType_ID',
  top: 20,
  skip: 0,
);

// 取得單筆記錄(含展開欄位)
final record = await api.getRecord('C_Order', 12345,
  expand: 'C_BPartner_ID,C_DocType_ID,M_Warehouse_ID',
);

// 新增記錄
final newRecord = await api.createRecord('C_Order', {
  'C_DocTypeTarget_ID': 132,
  'C_BPartner_ID': 118,
  'DateOrdered': '2026-02-17',
});

// 更新記錄
await api.updateRecord('C_Order', 12345, {
  'Description': 'Updated description',
});

// 刪除記錄
await api.deleteRecord('C_Order', 12345);

單欄位查詢

GET /api/v1/models/{TableName}/{id}/{ColumnName}

回應結構(集合)

{
  "page-count": 5,
  "records-size": 100,
  "skip-records": 0,
  "row-count": 450,
  "array-count": 100,
  "records": [...]
}

資料型別 — 讀取 vs 寫入

GET 回傳的型別與 POST/PUT 送出的型別不一定相同,這是常見錯誤來源:

型別 GET 回傳 POST/PUT 送出
String "text" "text"
Boolean true / false true / false
Numeric 100.00 100.00
DateTime "2025-01-15T08:30:00Z" "2025-01-15T08:30:00Z"(無毫秒)
FK(外鍵) {"id":102, "identifier":"Name"} 物件 數字 ID 102
List 參照 {"id":"DR", "identifier":"Drafted"} 物件 Value 字串 "DR"

⚠️ GET 回傳的 FK/List 是物件 — 取值用 .id,顯示用 .identifier
⚠️ DateTime Z 後綴實為本地時間 — 解析時不要做 UTC 轉換

OData 查詢

idempiere-rest 支援 OData 風格的查詢參數:

參數 用途 範例
$filter 篩選條件(等於) IsSOTrx eq true and DocStatus eq 'DR'
$filter 篩選條件(不等於) DocStatus neq 'VO'(排除已作廢單據)
$select 選取欄位 Name,Value,IsActive
$expand 展開外鍵欄位 C_BPartner_ID,C_DocType_ID
$orderby 排序 DateOrdered desc
$top 取得筆數 20
$skip 跳過筆數(分頁) 40
label 依標籤篩選(AD_Label) label=Name eq '%23Customer'
showlabel 回傳標籤資訊 showlabelshowlabel=Name

OData 篩選注意事項

這是最常見的 bug 來源。Boolean 欄位和 Char 欄位的篩選語法不同:

欄位類型 正確語法 錯誤語法
Boolean 欄位 IsActive eq true IsActive eq 'Y'(悄悄回傳錯誤資料)
Boolean 欄位 IsSOTrx eq true IsSOTrx eq 'Y'
Char 欄位 DocStatus eq 'CO'
不等於(neq) DocStatus neq 'VO' DocStatus ne 'VO'(iDempiere 用 neq,非標準 OData 的 ne)

例外:contains 等字串函式和 $context 必須用 'Y'/'N'(不是 true/false):

contains(IsSOTrx,'Y')          ← 字串函式用 'Y'
$context=IsSOTrx:Y               ← context 用 Y(不加引號)

Document Framework 透過 StatusFilter.isBoolean 自動處理這個差異。

完整比較運算子

運算子 意義 範例
eq = Name eq 'Joe'
neq DocStatus neq 'VO'
gt > GrandTotal gt 1000
ge GrandTotal ge 1000
lt < GrandTotal lt 500
le GrandTotal le 500
in IN C_BPartner_ID in (119,120)

⚠️ ne 不支援,必須用 neq
⚠️ not (X eq Y) 不行,not 只能搭配函式

字串函式

contains(Name,'Garden')    startswith(Name,'Ga')    endswith(Name,'Co.')
tolower(Name) eq 'garden'  not contains(Name,'test')

$select 欄位選擇

GET /api/v1/models/M_Product?$select=Name,Value&$orderby=Name desc&$top=20&$skip=40

$expand — 展開關聯(進階用法)

# FK 展開(一對一)
?$expand=C_BPartner_ID

# 子表展開(一對多)
?$expand=C_OrderLine

# 帶過濾的子表(分號分隔參數)
?$expand=C_OrderLine($select=Line,LineNetAmt;$filter=LineNetAmt gt 1000;$orderby=Line)

# FK 欄位 $select
?$expand=M_Product_Category_ID($select=Name,IsDefault)

# 巢狀展開
?$expand=C_OrderLine($expand=C_OrderTax)

# 反向展開(自訂 join key)
?$expand=C_Order.SalesRep_ID($select=DocumentNo)

$valrule + $context — 套用驗證規則

直接在 server 端執行 AD_Val_Rule,不需手動轉 SQL → OData。搜尋參考欄位時,App 會自動將表單目前的欄位值作為 $context 傳送,確保驗證規則中的 @ColumnName@ 變數能正確解析:

GET /api/v1/models/C_DocType?$valrule=133&$context=IsSOTrx:Y
參數 格式 說明
$valrule AD_Val_Rule_ID 或 AD_Val_Rule_UU 驗證規則 ID 或 UUID
$context Key:Value 多個用逗號:IsSOTrx:Y,AD_Client_ID:11

⚠️ $context 的 Boolean 用 Y/N(不是 true/false,跟 $filter 不同!)

載入狀態追蹤

參考欄位搜尋支援即時載入狀態追蹤。當使用者輸入搜尋關鍵字時:

  1. 搜尋面板顯示載入指示器(shimmer 骨架動畫)
  2. 若搜尋條件變更,前一次尚未完成的請求會自動取消
  3. 載入完成後平滑過渡至結果列表
  4. 錯誤時顯示重試按鈕,不影響已選取的值

文件工作流程

iDempiere 文件(如銷售訂單、付款等)遵循三步驟工作流程:

# 1. 建立文件標頭
POST /api/v1/models/C_Order
{
  "C_DocTypeTarget_ID": 132,
  "C_BPartner_ID": 118,
  "DateOrdered": "2026-02-17"
}
# 回傳: { "id": 12345, ... }

# 2. 建立明細行
POST /api/v1/models/C_OrderLine
{
  "C_Order_ID": 12345,
  "M_Product_ID": 456,
  "QtyOrdered": 10
}

# 3. 完成文件(透過 Process 端點)
POST /api/v1/processes/c_order-process
{
  "record-id": 12345,
  "table-id": 259
}

⚠️ 不要用 PUT 更新 DocAction 欄位,應使用 Process 端點。新單據預設 DocAction='CO',POST Process 即觸發 Complete。

Document Process 對照表

Document Table ID Process Slug
C_Order 259 c_order-process
C_Invoice 318 c_invoice-process
C_Payment 335 c_payment-process
M_InOut 319 m_inout-process
M_Production 325 m_production-process
M_Inventory 321 m_inventory-process
M_Movement 323 m_movement-process

常用 doc-action 代碼

代碼 動作 說明
CO Complete 完成文件
VO Void 作廢文件
CL Close 關閉文件
RC Reverse – Correct 沖銷更正
PR Prepare 準備文件

攔截器

API 客戶端使用 Dio 攔截器處理橫切關注點:

攔截器 用途 說明
AuthInterceptor JWT 認證 自動附加 Bearer token,401 時自動刷新
ContextInterceptor 上下文標頭 自動附加 AD_Language 和上下文標頭
RetryInterceptor 網路重試 網路錯誤時自動重試(3 次,指數退避)

認證攔截器流程

HTTP 請求
  │
  ▼
AuthInterceptor 附加 Bearer token
  │
  ▼
伺服器回應
  │
  ├── 200 OK → 正常回傳
  │
  └── 401 Unauthorized
       │
       ▼
  自動刷新 token(PUT /api/v1/auth/tokens)
       │
       ▼
  使用新 token 重新發送原始請求

附件上傳/下載

// 上傳附件
final attachmentApi = AttachmentApi(api.dio);
await attachmentApi.upload(
  tableName: 'C_Order',
  recordId: 12345,
  file: selectedFile,
);

// 下載附件
final bytes = await attachmentApi.download(
  tableName: 'C_Order',
  recordId: 12345,
  attachmentId: 67890,
);

選單樹 API

// 取得角色的選單樹
final menuTree = await api.getMenuTree(treeId);
// 回傳巢狀選單項目,每個項目含有:
// - action: 'W' (Window), 'P' (Process), 'R' (Report), 'X' (Form)
// - targetId: AD_Window_ID / AD_Process_ID / AD_Form_ID
// - name, description

表格對應參考

應用概念 iDempiere 表格 關鍵欄位
銷售訂單 C_Order DocumentNo, DateOrdered, C_BPartner_ID, GrandTotal, DocStatus
採購訂單 C_Order 同上(以 IsSOTrx=’N’ 區分)
發票 C_Invoice DocumentNo, DateInvoiced, C_BPartner_ID, GrandTotal, DocStatus
付款/收款 C_Payment DocumentNo, DateTrx, C_BPartner_ID, PayAmt, DocStatus
產品 M_Product Value, Name, M_Product_Category_ID, C_UOM_ID
商業夥伴 C_BPartner Value, Name, IsCustomer, IsVendor
庫存移動 M_Movement DocumentNo, MovementDate, Description, DocStatus
庫存移動明細 M_MovementLine M_Movement_ID, M_Product_ID, M_Locator_ID, M_LocatorTo_ID, MovementQty
總帳分錄 GL_Journal DocumentNo, DateAcct, Description, TotalDr, TotalCr
資產 A_Asset Value, Name, A_Asset_Group_ID, DateAcct

Application Dictionary (AD) 查詢

iDempiere 的表單結構是 metadata-driven,欄位定義在 AD_Window → AD_Tab → AD_Field → AD_Column。

查詢 Tab 的欄位定義

GET /api/v1/models/AD_Field?$filter=AD_Tab_ID eq {tabId} and IsActive eq true and IsDisplayed eq true
  &$select=AD_Field_ID,Name,SeqNo,IsReadOnly,AD_FieldGroup_ID,AD_Column_ID&$orderby=SeqNo&$top=200

查詢欄位的 Column 屬性

GET /api/v1/models/AD_Column/{columnId}
  ?$select=ColumnName,AD_Reference_ID,AD_Reference_Value_ID,FieldLength,IsMandatory,DefaultValue,IsUpdateable,AD_Val_Rule_ID

AD_Reference_ID → 元件類型

Ref ID 型別 前端元件
10 String text input
11 Integer number input
12 Amount number input (step 0.01)
13 ID hidden
14 Text textarea
15 Date date input
16 DateTime datetime-local input
17 List select(選項從 AD_Ref_List 載入)
18 Table FK selector(table 從 AD_Ref_Table 解析)
19 TableDirect FK selector(table 從 column name 推導)
20 YesNo checkbox
28 Button hidden(DocAction 等)
29 Quantity number input
30 Search FK selector(有 AD_Reference_Value_ID 走 AD_Ref_Table,沒有則從 column name 推導)
31 Locator FK selector(table 從 column name 推導)
38 Memo textarea

FK Table Name 解析邏輯

Ref 19/31 (TableDirect/Locator):
  column name 去掉 _ID → table name (M_Warehouse_ID → M_Warehouse)

Ref 18/30 (Table/Search) + 有 AD_Reference_Value_ID:
  AD_Ref_Table → AD_Table → TableName

Ref 18/30 (Table/Search) + 無 AD_Reference_Value_ID:
  fallback: column name 去掉 _ID → table name
  (大量核心欄位如 C_BPartner_ID, C_Order_ID 都是這種情況)

List 參照選項 (Ref 17)

GET /api/v1/models/AD_Ref_List?$filter=AD_Reference_ID eq {referenceValueId} and IsActive eq true
  &$select=Value,Name&$orderby=Name

Identifier Column(FK 顯示欄位)

GET /api/v1/models/AD_Column?$filter=AD_Table_ID eq {tableId} and IsIdentifier eq true and IsActive eq true
  &$select=ColumnName,SeqNo&$orderby=SeqNo&$top=1

必填欄位查詢

GET /api/v1/models/AD_Column?$filter=AD_Table_ID eq {tableId} and IsMandatory eq true and IsActive eq true
  &$select=ColumnName,DefaultValue,AD_Reference_ID

AD_Val_Rule 驗證規則

GET /api/v1/models/AD_Val_Rule/{ruleId}?$select=Code
# 回傳: { "Code": "C_DocType.DocBaseType IN ('SOO','POO') AND ..." }

優先用 $valrule 參數讓 server 端執行,避免手動轉 SQL → OData。

DefaultValue 解析規則

格式 意義 範例
Y / N Boolean true / false
@#Date@ 今天 2025-01-15
@AD_Org_ID@ Context 變數 當前組織 ID
@SQL=SELECT... SQL 查詢 由 server 處理
SYSDATE 當前時間 ISO datetime
數字 0, 100 整數/FK ID 依 Ref ID 判斷型別

Processes / Reports

POST /api/v1/processes/{slug}
{ "record-id": 123, "table-id": 208, "params": { "DateFrom": "2025-01-01" } }
項目 說明
slug 格式 AD_Process.Value 小寫(如 pp_product_bomc_order-process
⚠️ 不能用數字 ID processes/136 回 404
record-bound record-id + table-id 用於綁定記錄的 process
回傳 summaryisErrorlogs;報表回傳 PDF/HTML

其他功能

# 顯示生成的 SQL(debug 用)
GET /api/v1/models/C_Order/100?showsql
GET /api/v1/models/C_Order/100?showsql=nodata

# REST Views(自訂視圖)
GET /api/v1/views/{viewName}

# 建立記錄連同子記錄(單次 POST)
POST /api/v1/models/C_Order
{ "C_BPartner_ID": 119,
  "C_OrderLine": [{ "M_Product_ID": 122, "QtyOrdered": 10 }] }

label / showlabel — 標籤篩選

透過 AD_Label 標籤篩選和查詢記錄:

# 篩選有 #Customer 標籤的商業夥伴(# 需 URL encode 為 %23)
GET /api/v1/models/C_BPartner?label=Name eq '%23Customer'

# 查詢單筆記錄的標籤(回傳完整標籤資訊)
GET /api/v1/models/C_BPartner/119?showlabel&$select=Name,IsCustomer,IsVendor
# 回傳: "assigned-labels": [{"Name":"#Customer","Description":"Customers"}]

# 只回傳標籤名稱
GET /api/v1/models/C_BPartner/119?showlabel=Name
# 回傳: "assigned-labels": ["#Customer","#Vendor"]

label 也支援在 $expand 內巢狀使用。

常見陷阱速查 ⚠️

陷阱 說明
ne 不支援 neq
not (X eq Y) 不行 not 只能搭配函式如 not contains()
IsActive eq 'Y' 'Y' 被當欄位名,正確: IsActive eq true
$context boolean Y/N(不是 true/false
$filter boolean true/false(不是 'Y'/'N'
PUT /auth/tokens 回傳新 token,舊 token 立即失效
Refresh token 只能用一次,重複使用觸發安全封鎖
DateTime Z 後綴 實為本地時間,不要做 UTC 轉換
GET FK 回傳物件 {"id":102, "identifier":"Name"},取值用 .id
POST FK 傳數字 直接傳 ID 數字,不是物件
DocAction 不要 PUT 更新欄位,走 Process 端點
C_BPartner 單獨建 ❌ 必須連同 C_Location → C_BPartner_Location → AD_User 一起建
BPartner 無地址 訂單/出入庫需要 C_BPartner_Location_ID
價目表無產品 M_ProductPrice 為空時要 fallback 到全部 M_Product
倉庫預設值 優先用登入時選的 warehouseId

常用查詢範例

# 客戶列表
GET /api/v1/models/C_BPartner?$filter=IsCustomer eq true and IsActive eq true&$orderby=Name&$top=50

# 搜尋產品
GET /api/v1/models/M_Product?$filter=contains(Name,'玻尿酸') and IsActive eq true&$select=Name,Value

# 訂單 + 明細
GET /api/v1/models/C_Order/100?$expand=C_OrderLine($orderby=Line)

# 單據類型(銷售)
GET /api/v1/models/C_DocType?$valrule=133&$context=IsSOTrx:Y&$select=Name

# BOM 產品
GET /api/v1/models/M_Product?$filter=IsBOM eq true and IsVerified eq true

# 完成訂單
POST /api/v1/processes/c_order-process  { "record-id": 100, "table-id": 259 }

# 依標籤篩選
GET /api/v1/models/C_BPartner?label=Name eq '%23Customer'&$select=Name,Value

ODataFilter / ODataExpand Builder 工具

專案提供了型別安全的 ODataFilter 建構器(lib/core/utils/odata_filter_builder.dart),用於產生正確的 filter 語法:

import 'package:idempiere_app/core/utils/odata_filter_builder.dart';

// Boolean 欄位 — 產生: IsActive eq true
ODataFilter().boolEq('IsActive', true)

// String 欄位 — 產生: DocStatus eq 'CO'
ODataFilter().eq('DocStatus', 'CO')

// 不等於 — 產生: Status neq 'CO'(永遠用 neq,不是 ne)
ODataFilter().neq('Status', 'CO')

// 組合條件 — 產生:
// IsVendor eq true and IsActive eq true and (contains(Name,'acme') or contains(Value,'acme'))
ODataFilter()
    .boolEq('IsVendor', true)
    .boolEq('IsActive', true)
    .or_(["contains(Name,'acme')", "contains(Value,'acme')"])
    .build()

⚠️ 字串值中的單引號會自動跳脫(O'BrienO''Brien)。

ODataExpand Builder

ODataExpand 建構器(lib/core/utils/odata_expand_builder.dart)支援 $expand 搭配子篩選:

import 'package:idempiere_app/core/utils/odata_expand_builder.dart';

// 簡單展開: C_BPartner_ID
ODataExpand('C_BPartner_ID').build()

// 帶子篩選: C_OrderLine($select=Line,Amt;$filter=Amt gt 100;$orderby=Line)
ODataExpand('C_OrderLine')
    .select('Line,Amt')
    .filter('Amt gt 100')
    .orderBy('Line')
    .build()

// 多重展開
buildExpands([ODataExpand('C_BPartner_ID'), ODataExpand('C_OrderLine').select('Line')])

Builder 方法速查

Builder 方法 說明
ODataFilter .boolEq(field, value) Boolean 欄位篩選(不加引號)
ODataFilter .eq(field, value) 等於篩選(自動加引號)
ODataFilter .neq(field, value) 不等於篩選
ODataFilter .or_(conditions) OR 條件群組
ODataFilter .build() 產生最終 filter 字串
ODataExpand .select(fields) 子查詢 $select
ODataExpand .filter(expr) 子查詢 $filter
ODataExpand .orderBy(expr) 子查詢 $orderby
ODataExpand .build() 產生單一 expand 字串
函式 buildExpands(list) 組合多個 expand 為逗號分隔字串

SQL 預設值解析(Default Value Resolution)

新增或複製記錄時,系統自動解析 AD_Column.DefaultValue,將預設值填入表單。特別是 @SQL=SELECT... 格式的預設值,需要伺服器端執行查詢。

相關檔案

檔案 用途
lib/features/window/domain/models/field_info.dart defaultValue 屬性儲存 AD_Column.DefaultValue 原始字串
lib/features/window/data/window_repository.dart resolveDefaultValues() 方法批次解析所有欄位預設值
lib/features/window/presentation/record_form_screen.dart 新增記錄時呼叫預設值解析,填入表單初始值

支援的預設值模式

模式 範例 處理方式
@SQL=SELECT... @SQL=SELECT MAX(Line)+10 FROM C_OrderLine WHERE C_Order_ID=@C_Order_ID@ 伺服器端執行 SQL,回傳結果值
@ColumnName@ @AD_Org_ID@@M_Warehouse_ID@ 替換為目前登入 Context 的值
@#Date@ @#Date@ 替換為今天日期
固定值 YN0 直接使用

Context 變數替換

SQL 預設值中的 @ColumnName@ 變數替換順序:

  1. 優先從表單目前欄位值取得(已填入的欄位)
  2. 再從登入 Context 取得(AD_Client_ID、AD_Org_ID、#Date 等)
  3. 若仍無法解析,保留原始 @Variable@(由伺服器端處理)

API 呼叫方式

SQL 預設值透過 OData 查詢端點執行,將 @SQL=SELECT... 中的 SQL 轉換為 OData 格式:

# 例:取得下一行行號
GET /api/v1/models/C_OrderLine?$filter=C_Order_ID eq 12345&$select=Line&$orderby=Line desc&$top=1

API 回應處理

伺服器回傳預設值後,系統依欄位的 AD_Reference_ID 自動轉型:

  • FK 欄位(18/19/30):數字 ID → 查詢 identifier 後組成 {"id": N, "identifier": "..."}
  • Boolean 欄位(20):"Y"/"N"true/false
  • 日期欄位(15/16):字串 → ISO 格式日期
  • 數字欄位(11/12/22/29):字串 → 數字

圖片欄位顯示(Image Field Display)

支援 AD_Reference_ID 為 32(Image)和 33(Image URL)的欄位顯示。圖片以 base64 內嵌方式傳輸,無需額外的檔案伺服器。

JSON 結構

// GET 回傳的圖片欄位值
{
  "Logo_ID": {
    "id": 12345,
    "identifier": "company-logo.png",
    "model-name": "ad_image"
  }
}

// 取得圖片內容
GET /api/v1/models/AD_Image/12345
// 回傳:
{
  "BinaryData": "iVBORw0KGgo...",   // base64 encoded
  "Name": "company-logo.png"
}

顯示邏輯(4 步驟)

  1. 偵測圖片欄位:判斷 AD_Reference_ID 為 32 或 33
  2. 載入圖片資料:透過 AD_Image 表取得 base64 編碼的 BinaryData
  3. 預覽顯示:在記錄詳情中以縮圖呈現(最大寬度 120px),載入中顯示骨架動畫
  4. 全螢幕檢視:點擊縮圖開啟全螢幕畫面,支援雙指縮放(pinch-to-zoom)與拖曳平移

注意事項

  • 圖片資料快取於記憶體中,同一 session 不重複下載
  • BinaryData 為空或解碼失敗,顯示破損圖片圖示
  • 編輯模式下圖片欄位為唯讀(不支援從 App 上傳圖片)

Callout API(欄位變更觸發)

提供伺服器端欄位變更觸發(Callout)功能,與 iDempiere WebUI 的 Callout 行為一致。

API 端點

POST /api/v1/callout/{windowSlug}/{tabSlug}

Request Body

{
  "columnName": "C_BPartner_ID",
  "value": {"id": 123, "identifier": "Joe Block", "model-name": "c_bpartner"},
  "record": {
    "C_DocType_ID": {"id": 132},
    "DateOrdered": "2026-02-21",
    "C_BPartner_ID": {"id": 123}
  },
  "recordId": 0
}
欄位 說明
columnName 觸發 Callout 的欄位名稱
value 該欄位的新值
record 表單目前所有欄位值(完整表單狀態)
recordId 記錄 ID(0 = 新記錄,> 0 = 既有記錄)

Response Body

{
  "changedFields": {
    "Bill_BPartner_ID": {"id": 123, "identifier": "Joe Block"},
    "C_PaymentTerm_ID": {"id": 105, "identifier": "Immediate"},
    "M_PriceList_ID": {"id": 101, "identifier": "Standard"}
  }
}

changedFields 僅包含被 Callout 修改的欄位。若無欄位被變更,回傳空物件 {}

Callout 來源類型

兩種 Callout 來源均支援——不管你的 Callout 是在 Application Dictionary 裡設定的,還是用 Java 寫的 Factory,通通能觸發:

  • AD_Column.Callout:在 Application Dictionary 中設定的 Callout class 名稱
  • IColumnCalloutFactory / IColumnCallout:透過 OSGi 註冊的 Callout Factory(如 HRMCalloutFactory

架構圖

Flutter App                        iDempiere Server
─────────────                      ────────────────
onChanged(field, value)
    │
    ▼
fireCallout() ──POST──►  /api/v1/callout/{windowSlug}/{tabSlug}
                                    │
                                    ▼
                          CalloutResourceImpl.fireCallout()
                                    │
                          ┌─────────┴──────────┐
                          │  1. Resolve window  │
                          │  2. Create GridTab  │
                          │  3. Load/new record │
                          │  4. Set all fields  │
                          │  5. Set trigger col │
                          │     (fires callout) │
                          │  6. Diff before/after│
                          │  7. Return changes  │
                          │  8. Discard (no save)│
                          └─────────┬──────────┘
                                    │
fireCallout() ◄──response──  {"changedFields":{...}}
    │
    ▼
setState(() => formData.addAll(changed))

伺服器端處理流程(Java 程式碼)

// 1. 解析 Window/Tab(透過 slug)
MWindow window = new Query(ctx, MWindow.Table_Name, "slugify(name)=?", null)
    .setParameters(windowSlug).first();
GridWindowVO vo = GridWindowVO.create(ctx, 1, window.getAD_Window_ID());
GridWindow gridWindow = new GridWindow(vo, true);

// 2. 初始化 GridTab
GridTab targetTab = gridWindow.getTab(tabIndex);
targetTab.initTab(false);
targetTab.getTableModel().setImportingMode(false, null);

// 3. 載入或新增記錄
if (recordId > 0) {
    MQuery query = new MQuery(tableName);
    query.addRestriction(keyColumn, "=", recordId);
    targetTab.setQuery(query);
    targetTab.query(false);
} else {
    targetTab.dataNew(false);
}

// 4. 設定所有欄位(觸發欄位最後設定)
for (field : record.entrySet()) {
    if (!field.equals(columnName)) {
        gridTab.setValue(gridField, convertedValue);
    }
}

// 5. 設定觸發欄位 → 觸發 Callout
gridTab.setValue(triggerField, triggerValue);
gridTab.processFieldChange(triggerField);  // fires both AD_Column.Callout & IColumnCallout

// 6. 比較前後差異,回傳 changedFields
// 7. 丟棄(不儲存)
targetTab.dataIgnore();

Flutter 客戶端整合

修改檔案

檔案 變更
lib/features/window/domain/models/field_info.dart 新增 callout 屬性與 hasCallout getter
lib/features/window/data/window_repository.dart 新增 fireCallout() 方法
lib/features/window/presentation/record_detail_screen.dart onChanged 呼叫 _fireCallout()
lib/features/window/presentation/record_form_screen.dart onChanged 呼叫 _fireCallout()

fireCallout() 方法

Future<Map<String, dynamic>?> fireCallout({
  required String windowSlug,
  required String tabSlug,
  required String columnName,
  required dynamic value,
  required Map<String, dynamic> formData,
  required int recordId,
}) async {
  final response = await _api.dio.post(
    '${ApiConstants.apiPrefix}/callout/$windowSlug/$tabSlug',
    data: {
      'columnName': columnName,
      'value': value,
      'record': formData,
      'recordId': recordId,
    },
  );
  // parse changedFields, return if non-empty
}

UI 整合

Future<void> _fireCallout(String columnName, dynamic value) async {
  final changed = await repo.fireCallout(
    windowSlug: widget.windowSlug,
    tabSlug: headerTab.slug,
    columnName: columnName,
    value: value,
    formData: _formData,
    recordId: _currentRecordId,
  );
  if (changed != null && changed.isNotEmpty && mounted) {
    setState(() { _formData.addAll(changed); });
  }
}

每個可編輯欄位的 onChanged 回呼中:

  1. 更新 _formData[columnName] = value
  2. 呼叫 _fireCallout(columnName, value)
  3. 回傳的 changedFields 合併至 _formData,觸發 UI 重繪

ApplicationV1 動態載入程式碼

public class ApplicationV1 extends Application {
    @Override
    public Set<Class<?>> getClasses() {
        final Set<Class<?>> classes = new HashSet<>();

        // 1. 硬編碼核心 resource
        classes.add(AuthServiceImpl.class);
        classes.add(ModelResourceImpl.class);
        classes.add(WindowResourceImpl.class);
        // ... 共 15 個 ...

        // 2. 動態發現外部 plugin 的 resource
        IServicesHolder<ResourceExtension> list =
            Service.locator().list(ResourceExtension.class);
        for (IServiceReferenceHolder<ResourceExtension> holder
                : list.getServiceReferences()) {
            ResourceExtension service = holder.getService();
            if (service != null) {
                classes.addAll(service.getResourceClasses());
            }
        }

        return classes;
    }
}

要點:getClasses() 在 Jersey servlet 初始化時呼叫一次(非每次請求)。Service.locator() 底層使用 OSGi ServiceTracker,所有 ResourceExtension 實作的 resource class 與核心 class 合併成同一個路由表。

部署步驟

完整部署四步走——比泡麵還簡單(好吧,至少跟泡麵一樣簡單):

  1. 匯入 Eclipse workspace:將 tw.idempiere.rest 專案匯入你的 Eclipse workspace
  2. 加入 Launch Config:在 server.product.launchselected_workspace_bundles 加入 tw.idempiere.rest@default:default
  3. 重啟 iDempiere Server:重啟後 Jersey 會在初始化時自動發現新的 ResourceExtension
  4. 驗證部署:在 OSGi console 執行 ss tw.idempiere.rest,確認狀態為 ACTIVE

伺服器插件架構(tw.idempiere.rest)

檔案 用途
CalloutResourceExtension.java 實作 ResourceExtension,向 REST API 註冊 JAX-RS 資源
CalloutResourceImpl.java REST 端點實作,@Path("v1/callout")
OSGI-INF/tw.idempiere.rest.CalloutResourceExtension.xml OSGi DS 組件描述檔
META-INF/MANIFEST.MF OSGi Bundle 設定,Activator 為 AdempiereActivator

OSGi Bundle 架構與路由機制

tw.idempiere.rest獨立 OSGi Bundle,不是 Fragment Bundle:

特徵 Fragment Bundle 獨立 Bundle(tw.idempiere.rest)
Fragment-Host 有(指向宿主 bundle) 沒有
Bundle-Activator 不能有 有(AdempiereActivator
獨立生命週期 否,附著在宿主上 是,獨立 ACTIVE
自己的 classloader 否,共用宿主的

兩個 Bundle 透過 OSGi Service Registry 連結,非 fragment 注入。idempiere-rest 核心匯出 ResourceExtension 介面,tw.idempiere.rest 實作該介面並註冊為 OSGi Service。各自獨立運行,擁有獨立的 classloader 與生命週期。

HTTP 請求路由鏈

POST /api/v1/callout/sales-order/order
      │
      ▼
  Jetty Web Server
      │  (Web-ContextPath: /api → idempiere-rest MANIFEST.MF)
      ▼
  Jersey ServletContainer (web.xml url-pattern: /*)
      │
      ▼
  ApplicationV1.getClasses() — JAX-RS Application,啟動時呼叫一次
      │
      ├── 硬編碼 15 個核心 resource class
      │   ├── AuthServiceImpl        → @Path("v1/auth")
      │   ├── ModelResourceImpl      → @Path("v1/models")
      │   ├── WindowResourceImpl     → @Path("v1/windows")
      │   └── ...
      │
      └── 動態發現 ResourceExtension(OSGi 服務)
          │  Service.locator().list(ResourceExtension.class)
          │       ↓
          │  DynamicServiceLocator → OSGi ServiceTracker
          │       ↓
          └── CalloutResourceExtension.getResourceClasses()
              → 返回 {CalloutResourceImpl.class}
      │
      ▼
  Jersey 統一路由表:
      v1/auth/**       → AuthServiceImpl        (idempiere-rest)
      v1/models/**     → ModelResourceImpl       (idempiere-rest)
      v1/callout/**    → CalloutResourceImpl     (tw.idempiere.rest)

ApplicationV1 動態載入機制

public class ApplicationV1 extends Application {
    @Override
    public Set<Class<?>> getClasses() {
        final Set<Class<?>> classes = new HashSet<>();
        // 1. 硬編碼核心 resource(15 個)
        classes.add(AuthServiceImpl.class);
        classes.add(ModelResourceImpl.class);
        // ...
        // 2. 動態發現外部 plugin 的 resource
        IServicesHolder<ResourceExtension> list =
            Service.locator().list(ResourceExtension.class);
        for (IServiceReferenceHolder<ResourceExtension> holder
                : list.getServiceReferences()) {
            ResourceExtension service = holder.getService();
            if (service != null) {
                classes.addAll(service.getResourceClasses());
            }
        }
        return classes;
    }
}

要點getClasses() 在 Jersey servlet 初始化時呼叫一次,非每次請求。所有 ResourceExtension 實作的 class 與核心 class 合併成同一個路由表,Jersey 根據 @Path 註解區分路由。

新增 REST 端點的模式

若未來需要擴充更多端點,只需在 tw.idempiere.rest 中:

  1. 建立新的 @Path("v1/xxx") resource class
  2. CalloutResourceExtension.getResourceClasses() 中加入該 class
  3. 重啟 iDempiere — Jersey 重新初始化時會自動發現

無需修改 idempiere-rest 核心插件的任何程式碼。

Flutter 客戶端整合

檔案 變更
field_info.dart 新增 callout 屬性與 hasCallout getter
window_repository.dart 新增 fireCallout() 方法
record_detail_screen.dart onChanged 回呼中呼叫 _fireCallout()
record_form_screen.dart onChanged 回呼中呼叫 _fireCallout()
// window_repository.dart
Future<Map<String, dynamic>?> fireCallout({
  required String windowSlug,
  required String tabSlug,
  required String columnName,
  required dynamic value,
  required Map<String, dynamic> formData,
  required int recordId,
}) async {
  final response = await _api.dio.post(
    '${ApiConstants.apiPrefix}/callout/$windowSlug/$tabSlug',
    data: {
      'columnName': columnName,
      'value': value,
      'record': formData,
      'recordId': recordId,
    },
  );
  // parse changedFields, return if non-empty
}

// record_detail_screen.dart / record_form_screen.dart
Future<void> _fireCallout(String columnName, dynamic value) async {
  final changed = await repo.fireCallout(
    windowSlug: widget.windowSlug,
    tabSlug: headerTab.slug,
    columnName: columnName,
    value: value,
    formData: _formData,
    recordId: _currentRecordId,
  );
  if (changed != null && changed.isNotEmpty && mounted) {
    setState(() { _formData.addAll(changed); });
  }
}

每個可編輯欄位的 onChanged 回呼中:更新 _formData、呼叫 _fireCallout()、回傳的 changedFields 合併至 _formData 觸發 UI 重繪。

🌐 English Version

iDempiere REST API Overview

iDempiere Mobile communicates with iDempiere ERP via the idempiere-rest plugin’s OData REST API.

Authentication Flow (Two Steps)

  1. POST /api/v1/auth/tokens with username/password → returns temporary token + client/role list
  2. PUT /api/v1/auth/tokens with clientId/roleId/orgId → returns session JWT token

The token is stored in FlutterSecureStorage and attached to all subsequent requests via AuthInterceptor.

One-Step Login

If all IDs are known, login can be done in a single POST with a parameters object containing clientId, roleId, organizationId, warehouseId, language.

Token Management

Token expires in 1 hour. Refresh token (24h) is single-use — reuse triggers security lockout. Endpoints: POST /api/v1/auth/refresh, POST /api/v1/auth/logout. Only roles with Role Type = WebService or blank can use API login.

CRUD Operations

All data access uses GET/POST/PUT/DELETE on /api/v1/models/{tableName}. Single column: GET /api/v1/models/{Table}/{id}/{Column}.

Data Types — Read vs Write

FK fields return objects ({"id":102, "identifier":"Name"}) on GET but require plain numeric IDs on POST/PUT. List references return objects but require the Value string (e.g. "DR"). DateTime Z suffix is local time — do not convert to UTC.

OData Queries

Supported parameters: $filter, $select, $expand, $orderby, $top, $skip, label, showlabel.

OData Filter Gotcha

Boolean columns use unquoted true/false. Char columns use quoted values like 'Y'. Using IsActive eq 'Y' silently returns wrong data.

Not equal: Use neq (not ne). Full operators: eq, neq, gt, ge, lt, le, in. String functions: contains(), startswith(), endswith(), tolower().

$expand — Advanced Usage

FK expand: $expand=C_BPartner_ID. Child table: $expand=C_OrderLine. Filtered: $expand=C_OrderLine($filter=LineNetAmt gt 1000). Nested: $expand=C_OrderLine($expand=C_OrderTax).

$valrule + $context — Validation Rules

Execute AD_Val_Rule server-side. When searching reference fields, the app auto-sends current form field values as $context, ensuring @ColumnName@ variables in validation rules resolve correctly. Example: $valrule=133&$context=IsSOTrx:Y. $valrule accepts AD_Val_Rule_ID or AD_Val_Rule_UU (UUID). Note: $context booleans use Y/N (not true/false).

label / showlabel — Label Filtering

Filter records by AD_Label: label=Name eq '%23Customer' (#%23 URL encoding). View assigned labels: showlabel (full info) or showlabel=Name (names only). Supports nested use within $expand.

Document Workflow

  1. POST to create header
  2. POST to create line items
  3. POST to /api/v1/processes/{process-slug} to complete (do NOT use PUT with doc-action)

Application Dictionary (AD)

iDempiere forms are metadata-driven (AD_Window → AD_Tab → AD_Field → AD_Column). Query AD_Field for tab layouts, AD_Column for field attributes, AD_Ref_List for list options. AD_Reference_ID maps to UI component types (10=String, 17=List, 18=Table, 19=TableDirect, 20=YesNo, 30=Search).

Processes / Reports

POST /api/v1/processes/{slug} — slug = AD_Process.Value in lowercase. Cannot use numeric IDs. Returns summary, isError, logs.

Interceptors

Interceptor Purpose
AuthInterceptor JWT Bearer token + auto-refresh on 401
ContextInterceptor AD_Language and context headers
RetryInterceptor Automatic retry on network errors (3 attempts, exponential backoff)

Attachments

Use AttachmentApi for file upload/download operations on any record.

Table Mapping

Concept Table Key Fields
Sales Order C_Order DocumentNo, DateOrdered, C_BPartner_ID, GrandTotal
Invoice C_Invoice DocumentNo, DateInvoiced, C_BPartner_ID, GrandTotal
Payment C_Payment DocumentNo, DateTrx, C_BPartner_ID, PayAmt
Product M_Product Value, Name, M_Product_Category_ID
Business Partner C_BPartner Value, Name, IsCustomer, IsVendor
Movement M_Movement DocumentNo, MovementDate, DocStatus
GL Journal GL_Journal DocumentNo, DateAcct, TotalDr, TotalCr

SQL Default Value Resolution

When creating or copying records, the system auto-resolves AD_Column.DefaultValue to populate form fields. @SQL=SELECT... defaults are executed server-side, @ColumnName@ variables are replaced with current login context values, and @#Date@ resolves to today’s date. Context variable resolution prioritizes current form values, then login context.

Supported Patterns

Pattern Example Handling
@SQL=SELECT... @SQL=SELECT MAX(Line)+10 FROM C_OrderLine WHERE C_Order_ID=@C_Order_ID@ Server-side SQL execution
@ColumnName@ @AD_Org_ID@ Replaced with login context value
@#Date@ @#Date@ Replaced with today’s date
Literal Y, 0 Used directly

Image Field Display

Supports AD_Reference_ID 32 (Image) and 33 (Image URL). Images are transferred as base64 via the AD_Image table. Display logic: detect image field → load base64 BinaryData → show thumbnail (120px max) → tap for fullscreen with pinch-to-zoom and pan.

Callout API (Field Change Triggers)

Provides server-side field change triggers (Callouts), consistent with iDempiere WebUI Callout behavior.

API Endpoint

POST /api/v1/callout/{windowSlug}/{tabSlug}

Request Body

{
  "columnName": "C_BPartner_ID",
  "value": {"id": 123, "identifier": "Joe Block", "model-name": "c_bpartner"},
  "record": {
    "C_DocType_ID": {"id": 132},
    "DateOrdered": "2026-02-21",
    "C_BPartner_ID": {"id": 123}
  },
  "recordId": 0
}
Field Description
columnName Column that triggered the Callout
value New value for the column
record All current form field values (complete form state)
recordId Record ID (0 = new record, > 0 = existing record)

Response Body

{
  "changedFields": {
    "Bill_BPartner_ID": {"id": 123, "identifier": "Joe Block"},
    "C_PaymentTerm_ID": {"id": 105, "identifier": "Immediate"},
    "M_PriceList_ID": {"id": 101, "identifier": "Standard"}
  }
}

changedFields only contains fields modified by the Callout. Returns empty object {} if no fields were changed.

Server-Side Processing

  1. Resolve Window/Tab via slug
  2. Initialize GridTab
  3. Load or create record (new record when recordId = 0)
  4. Set all field values (trigger field set last)
  5. Execute gridTab.processFieldChange(triggerField) — fires AD_Column.Callout and IColumnCallout
  6. Compare before/after, return changedFields
  7. Discard (don’t save) — stateless design, no server-side memory accumulation

Deployment Steps

Four easy steps to deploy — easier than instant noodles (okay, maybe about the same):

  1. Import into Eclipse workspace: Import the tw.idempiere.rest project into your Eclipse workspace
  2. Add to Launch Config: Add tw.idempiere.rest@default:default to selected_workspace_bundles in server.product.launch
  3. Restart iDempiere Server: After restart, Jersey will auto-discover the new ResourceExtension during initialization
  4. Verify deployment: Run ss tw.idempiere.rest in the OSGi console and confirm the status is ACTIVE

Server Plugin Architecture (tw.idempiere.rest)

File Purpose
CalloutResourceExtension.java Implements ResourceExtension, registers JAX-RS resources with REST API
CalloutResourceImpl.java REST endpoint implementation, @Path("v1/callout")
OSGI-INF/...CalloutResourceExtension.xml OSGi DS component descriptor
META-INF/MANIFEST.MF OSGi Bundle configuration, Activator: AdempiereActivator

OSGi Bundle Architecture & Routing

tw.idempiere.rest is a standalone OSGi Bundle (not a Fragment Bundle). It connects to idempiere-rest core via OSGi Service Registry. The core exports the ResourceExtension interface; our plugin implements it and registers as an OSGi Service.

HTTP Request Routing Chain

Jetty → Jersey ServletContainer → ApplicationV1.getClasses() discovers both hardcoded core resources (15 classes) and dynamically discovered ResourceExtension services. Jersey builds a unified routing table from all @Path annotations. v1/callout/** routes to CalloutResourceImpl from tw.idempiere.rest.

Adding New REST Endpoints

  1. Create a new @Path("v1/xxx") resource class in tw.idempiere.rest
  2. Add the class to CalloutResourceExtension.getResourceClasses()
  3. Restart iDempiere — Jersey auto-discovers on init

No modifications to the idempiere-rest core plugin required.

Flutter Client Integration

Key files: field_info.dart (adds callout property), window_repository.dart (adds fireCallout() method), record_detail_screen.dart / record_form_screen.dart (call _fireCallout() on field change). The changedFields from the response are merged into _formData to trigger UI rebuild.

Callout Source Types

Both callout sources are supported:

  • AD_Column.Callout: Callout class configured in the Application Dictionary
  • IColumnCalloutFactory / IColumnCallout: Callout Factory registered via OSGi (e.g. HRMCalloutFactory)

Architecture

Flutter App                        iDempiere Server
─────────────                      ────────────────
onChanged(field, value)
    │
    ▼
fireCallout() ──POST──►  /api/v1/callout/{windowSlug}/{tabSlug}
                                    │
                                    ▼
                          CalloutResourceImpl.fireCallout()
                          (resolve → GridTab → set fields → trigger → diff → discard)
                                    │
fireCallout() ◄──response──  {"changedFields":{...}}
    │
    ▼
setState(() => formData.addAll(changed))

Server-Side Processing (Java)

// 1. Resolve Window/Tab via slug
MWindow window = new Query(ctx, MWindow.Table_Name, "slugify(name)=?", null)
    .setParameters(windowSlug).first();

// 2. Initialize GridTab
GridTab targetTab = gridWindow.getTab(tabIndex);
targetTab.initTab(false);

// 3. Load or create record
if (recordId > 0) { targetTab.query(false); }
else { targetTab.dataNew(false); }

// 4. Set all fields (trigger column last)
// 5. gridTab.processFieldChange(triggerField) — fires callouts
// 6. Diff before/after → return changedFields
// 7. Discard (no save)
targetTab.dataIgnore();

Flutter Client Integration

Key files: field_info.dart (added callout property), window_repository.dart (fireCallout() method), record_detail_screen.dart / record_form_screen.dart (call _fireCallout() on field change).

Future<Map<String, dynamic>?> fireCallout({
  required String windowSlug, required String tabSlug,
  required String columnName, required dynamic value,
  required Map<String, dynamic> formData, required int recordId,
}) async {
  final response = await _api.dio.post(
    '${ApiConstants.apiPrefix}/callout/$windowSlug/$tabSlug',
    data: {'columnName': columnName, 'value': value,
           'record': formData, 'recordId': recordId},
  );
  // parse changedFields, return if non-empty
}

ApplicationV1 Dynamic Loading

ApplicationV1.getClasses() discovers both hardcoded core resources (15 classes) and dynamically registered ResourceExtension services via Service.locator().list(ResourceExtension.class). Called once during Jersey servlet initialization.

🇯🇵 日本語版

iDempiere REST API 概要

iDempiere Mobile は、idempiere-rest プラグインの OData REST API を通じて iDempiere ERP と通信します。

認証フロー(2ステップ)

  1. POST /api/v1/auth/tokens:ユーザー名/パスワードで認証 → 一時トークン+クライアント/ロール一覧を取得
  2. PUT /api/v1/auth/tokens:clientId/roleId/orgId を指定 → セッション JWT トークンを取得

トークンは FlutterSecureStorage に保存され、AuthInterceptor を通じて後続のすべてのリクエストに自動付与されます。

ワンステップログイン

すべての ID が既知の場合、parameters オブジェクトに clientIdroleIdorganizationIdwarehouseIdlanguage を含めて一度の POST でログインできます。

トークン管理

トークンは1時間で失効。リフレッシュトークン(24時間)は一度のみ使用可能 — 再利用するとセキュリティロックが発動します。エンドポイント:POST /api/v1/auth/refreshPOST /api/v1/auth/logout。API ログインは Role Type = WebService または空白のロールのみ可能です。

CRUD 操作

すべてのデータアクセスは、/api/v1/models/{tableName} に対する GET/POST/PUT/DELETE で行います。単一カラム:GET /api/v1/models/{Table}/{id}/{Column}

データ型 — 読み取り vs 書き込み

FK フィールドは GET で {"id":102, "identifier":"Name"} オブジェクトを返しますが、POST/PUT では数値 ID を送信します。List 参照はオブジェクトを返しますが Value 文字列(例:"DR")を送信します。DateTime の Z サフィックスはローカル時間です — UTC 変換しないでください。

OData クエリ

サポートされるパラメータ:$filter$select$expand$orderby$top$skiplabelshowlabel

OData フィルターの注意点

Boolean カラムは引用符なしの true/false を使用します。Char カラムは引用符付きの値を使用します。IsActive eq 'Y' と記述すると、エラーなく誤ったデータが返されるため注意が必要です。

不等号:neq を使用(ne ではない)。全演算子:eqneqgtgeltlein。文字列関数:contains()startswith()endswith()tolower()

$expand — 高度な使用法

FK 展開:$expand=C_BPartner_ID。子テーブル:$expand=C_OrderLine。フィルター付き:$expand=C_OrderLine($filter=LineNetAmt gt 1000)。ネスト:$expand=C_OrderLine($expand=C_OrderTax)

$valrule + $context — バリデーションルール

AD_Val_Rule をサーバー側で実行。参照フィールド検索時、アプリは現在のフォームフィールド値を $context として自動送信し、バリデーションルール内の @ColumnName@ 変数が正しく解決されるようにします。例:$valrule=133&$context=IsSOTrx:Y$valrule は AD_Val_Rule_ID または AD_Val_Rule_UU(UUID)を指定可能。注意:$context の Boolean は Y/Ntrue/false ではない)。

label / showlabel — ラベルフィルタリング

AD_Label でレコードを絞り込み:label=Name eq '%23Customer'#%23 URL エンコード)。付与済みラベルの取得:showlabel(詳細情報)または showlabel=Name(名前のみ)。$expand 内でもネストして使用可能。

ドキュメントワークフロー

  1. POST でヘッダーを作成
  2. POST で明細行を作成
  3. /api/v1/processes/{process-slug} に POST して完了処理(PUT で doc-action を使用しないこと)

Application Dictionary (AD)

iDempiere のフォームはメタデータ駆動(AD_Window → AD_Tab → AD_Field → AD_Column)。AD_Field でタブレイアウト、AD_Column でフィールド属性、AD_Ref_List でリストオプションを照会します。AD_Reference_ID は UI コンポーネント型にマッピング(10=String、17=List、18=Table、19=TableDirect、20=YesNo、30=Search)。

プロセス / レポート

POST /api/v1/processes/{slug} — slug = AD_Process.Value の小文字。数値 ID は使用不可。レスポンス:summaryisErrorlogs

インターセプター

インターセプター 目的
AuthInterceptor JWT Bearer トークンの付与 + 401 時の自動リフレッシュ
ContextInterceptor AD_Language およびコンテキストヘッダーの付与
RetryInterceptor ネットワークエラー時の自動リトライ(3回、指数バックオフ)

添付ファイル

任意のレコードに対するファイルのアップロード/ダウンロードには AttachmentApi を使用します。

テーブルマッピング

概念 テーブル 主要フィールド
受注伝票 C_Order DocumentNo, DateOrdered, C_BPartner_ID, GrandTotal
請求書 C_Invoice DocumentNo, DateInvoiced, C_BPartner_ID, GrandTotal
入出金 C_Payment DocumentNo, DateTrx, C_BPartner_ID, PayAmt
製品 M_Product Value, Name, M_Product_Category_ID
取引先 C_BPartner Value, Name, IsCustomer, IsVendor
在庫移動 M_Movement DocumentNo, MovementDate, DocStatus
仕訳帳 GL_Journal DocumentNo, DateAcct, TotalDr, TotalCr

SQL デフォルト値解決(Default Value Resolution)

レコードの新規作成またはコピー時、システムは AD_Column.DefaultValue を自動解決してフォームフィールドに初期値を設定します。@SQL=SELECT... デフォルトはサーバー側で実行、@ColumnName@ 変数は現在のログインコンテキスト値に置換、@#Date@ は本日の日付に解決されます。

サポートされるパターン

パターン 処理方法
@SQL=SELECT... @SQL=SELECT MAX(Line)+10 FROM C_OrderLine WHERE C_Order_ID=@C_Order_ID@ サーバー側で SQL 実行
@ColumnName@ @AD_Org_ID@ ログインコンテキスト値に置換
@#Date@ @#Date@ 本日の日付に置換
固定値 Y0 そのまま使用

画像フィールド表示(Image Field Display)

AD_Reference_ID 32(Image)および 33(Image URL)をサポート。画像は AD_Image テーブル経由で base64 として転送されます。表示ロジック:画像フィールドの検出 → base64 BinaryData の読み込み → サムネイル表示(最大 120px)→ タップでフルスクリーン(ピンチズーム・パン操作対応)。

Callout API(フィールド変更トリガー)

サーバー側のフィールド変更トリガー(Callout)機能を提供します。iDempiere WebUI の Callout 動作と一致します。

API エンドポイント

POST /api/v1/callout/{windowSlug}/{tabSlug}

Request Body

{
  "columnName": "C_BPartner_ID",
  "value": {"id": 123, "identifier": "Joe Block", "model-name": "c_bpartner"},
  "record": {
    "C_DocType_ID": {"id": 132},
    "DateOrdered": "2026-02-21",
    "C_BPartner_ID": {"id": 123}
  },
  "recordId": 0
}
フィールド 説明
columnName Callout をトリガーしたカラム名
value カラムの新しい値
record フォームの全フィールド値(完全なフォーム状態)
recordId レコード ID(0 = 新規レコード、> 0 = 既存レコード)

Response Body

{
  "changedFields": {
    "Bill_BPartner_ID": {"id": 123, "identifier": "Joe Block"},
    "C_PaymentTerm_ID": {"id": 105, "identifier": "Immediate"},
    "M_PriceList_ID": {"id": 101, "identifier": "Standard"}
  }
}

changedFields には Callout によって変更されたフィールドのみが含まれます。変更がない場合は空オブジェクト {} を返します。

サーバー側の処理フロー

  1. Window/Tab を slug で解決
  2. GridTab を初期化
  3. レコードの読み込みまたは新規作成(recordId = 0 の場合は新規)
  4. 全フィールド値を設定(トリガーフィールドは最後に設定)
  5. gridTab.processFieldChange(triggerField) を実行 — AD_Column.Callout および IColumnCallout を起動
  6. 前後を比較し、changedFields を返却
  7. 破棄(保存しない)— ステートレス設計、サーバー側のメモリ蓄積なし

デプロイ手順

完全デプロイ4ステップ——カップ麺より簡単(まあ、カップ麺と同じくらい簡単かも):

  1. Eclipse workspace にインポートtw.idempiere.rest プロジェクトを Eclipse workspace にインポートします
  2. Launch Config に追加server.product.launchselected_workspace_bundlestw.idempiere.rest@default:default を追加します
  3. iDempiere Server を再起動:再起動後、Jersey が初期化時に新しい ResourceExtension を自動検出します
  4. デプロイを確認:OSGi コンソールで ss tw.idempiere.rest を実行し、ステータスが ACTIVE であることを確認します

サーバープラグインアーキテクチャ(tw.idempiere.rest)

ファイル 用途
CalloutResourceExtension.java ResourceExtension を実装し、JAX-RS リソースを REST API に登録
CalloutResourceImpl.java REST エンドポイント実装、@Path("v1/callout")
OSGI-INF/...CalloutResourceExtension.xml OSGi DS コンポーネント記述子
META-INF/MANIFEST.MF OSGi Bundle 設定、Activator: AdempiereActivator

OSGi Bundle アーキテクチャとルーティング

tw.idempiere.rest独立 OSGi Bundle(Fragment Bundle ではない)です。OSGi Service Registry を通じて idempiere-rest コアと連携します。コアが ResourceExtension インターフェースをエクスポートし、本プラグインがそれを実装して OSGi サービスとして登録します。

HTTP リクエストルーティングチェーン

Jetty → Jersey ServletContainer → ApplicationV1.getClasses() がハードコードされたコアリソース(15クラス)と動的に検出された ResourceExtension サービスの両方を発見します。Jersey はすべての @Path アノテーションから統一ルーティングテーブルを構築します。v1/callout/**tw.idempiere.restCalloutResourceImpl にルーティングされます。

新しい REST エンドポイントの追加

  1. tw.idempiere.rest に新しい @Path("v1/xxx") リソースクラスを作成
  2. CalloutResourceExtension.getResourceClasses() にクラスを追加
  3. iDempiere を再起動 — Jersey が初期化時に自動検出

idempiere-rest コアプラグインの変更は不要です。

Flutter クライアント統合

主要ファイル:field_info.dartcallout プロパティ追加)、window_repository.dartfireCallout() メソッド追加)、record_detail_screen.dart / record_form_screen.dart(フィールド変更時に _fireCallout() を呼び出し)。レスポンスの changedFields_formData にマージされ、UI の再描画がトリガーされます。

Callout ソースタイプ

両方の Callout ソースに対応しています:

  • AD_Column.Callout:Application Dictionary で設定された Callout クラス
  • IColumnCalloutFactory / IColumnCallout:OSGi 経由で登録された Callout Factory(例:HRMCalloutFactory

アーキテクチャ図

Flutter App                        iDempiere Server
─────────────                      ────────────────
onChanged(field, value)
    │
    ▼
fireCallout() ──POST──►  /api/v1/callout/{windowSlug}/{tabSlug}
                                    │
                                    ▼
                          CalloutResourceImpl.fireCallout()
                          (解決 → GridTab → フィールド設定 → トリガー → 差分 → 破棄)
                                    │
fireCallout() ◄──response──  {"changedFields":{...}}
    │
    ▼
setState(() => formData.addAll(changed))

サーバー側の処理(Java コード)

// 1. slug で Window/Tab を解決
MWindow window = new Query(ctx, MWindow.Table_Name, "slugify(name)=?", null)
    .setParameters(windowSlug).first();

// 2. GridTab を初期化
GridTab targetTab = gridWindow.getTab(tabIndex);
targetTab.initTab(false);

// 3. レコードの読み込みまたは新規作成
if (recordId > 0) { targetTab.query(false); }
else { targetTab.dataNew(false); }

// 4. 全フィールド設定(トリガーカラムは最後)
// 5. gridTab.processFieldChange(triggerField) — Callout を起動
// 6. 前後を比較 → changedFields を返却
// 7. 破棄(保存しない)
targetTab.dataIgnore();

Flutter クライアント統合

主要ファイル:field_info.dartcallout プロパティ追加)、window_repository.dartfireCallout() メソッド追加)、record_detail_screen.dart / record_form_screen.dart(フィールド変更時に _fireCallout() を呼び出し)。

Future<Map<String, dynamic>?> fireCallout({
  required String windowSlug, required String tabSlug,
  required String columnName, required dynamic value,
  required Map<String, dynamic> formData, required int recordId,
}) async {
  final response = await _api.dio.post(
    '${ApiConstants.apiPrefix}/callout/$windowSlug/$tabSlug',
    data: {'columnName': columnName, 'value': value,
           'record': formData, 'recordId': recordId},
  );
  // changedFields をパースし、空でなければ返却
}

ApplicationV1 の動的読み込み

ApplicationV1.getClasses() はハードコードされたコアリソース(15クラス)と動的に検出された ResourceExtension サービスの両方を発見します。Service.locator().list(ResourceExtension.class) を使用し、Jersey サーブレット初期化時に一度だけ呼び出されます。

按 Enter 搜尋,ESC 關閉