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 |
回傳標籤資訊 | showlabel 或 showlabel=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 不同!)
載入狀態追蹤
參考欄位搜尋支援即時載入狀態追蹤。當使用者輸入搜尋關鍵字時:
- 搜尋面板顯示載入指示器(shimmer 骨架動畫)
- 若搜尋條件變更,前一次尚未完成的請求會自動取消
- 載入完成後平滑過渡至結果列表
- 錯誤時顯示重試按鈕,不影響已選取的值
文件工作流程
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_bom、c_order-process) |
| ⚠️ 不能用數字 ID | processes/136 回 404 |
| record-bound | record-id + table-id 用於綁定記錄的 process |
| 回傳 | summary、isError、logs;報表回傳 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'Brien → O''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@ |
替換為今天日期 |
| 固定值 | Y、N、0 |
直接使用 |
Context 變數替換
SQL 預設值中的 @ColumnName@ 變數替換順序:
- 優先從表單目前欄位值取得(已填入的欄位)
- 再從登入 Context 取得(AD_Client_ID、AD_Org_ID、#Date 等)
- 若仍無法解析,保留原始
@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 步驟)
- 偵測圖片欄位:判斷
AD_Reference_ID為 32 或 33 - 載入圖片資料:透過
AD_Image表取得 base64 編碼的BinaryData - 預覽顯示:在記錄詳情中以縮圖呈現(最大寬度 120px),載入中顯示骨架動畫
- 全螢幕檢視:點擊縮圖開啟全螢幕畫面,支援雙指縮放(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 回呼中:
- 更新
_formData[columnName] = value - 呼叫
_fireCallout(columnName, value) - 回傳的
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 合併成同一個路由表。
部署步驟
完整部署四步走——比泡麵還簡單(好吧,至少跟泡麵一樣簡單):
- 匯入 Eclipse workspace:將
tw.idempiere.rest專案匯入你的 Eclipse workspace - 加入 Launch Config:在
server.product.launch的selected_workspace_bundles加入tw.idempiere.rest@default:default - 重啟 iDempiere Server:重啟後 Jersey 會在初始化時自動發現新的 ResourceExtension
- 驗證部署:在 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 中:
- 建立新的
@Path("v1/xxx")resource class - 在
CalloutResourceExtension.getResourceClasses()中加入該 class - 重啟 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)
- POST
/api/v1/auth/tokenswith username/password → returns temporary token + client/role list - PUT
/api/v1/auth/tokenswith 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
- POST to create header
- POST to create line items
- 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
- Resolve Window/Tab via slug
- Initialize GridTab
- Load or create record (new record when recordId = 0)
- Set all field values (trigger field set last)
- Execute
gridTab.processFieldChange(triggerField)— fires AD_Column.Callout and IColumnCallout - Compare before/after, return changedFields
- 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):
- Import into Eclipse workspace: Import the
tw.idempiere.restproject into your Eclipse workspace - Add to Launch Config: Add
tw.idempiere.rest@default:defaulttoselected_workspace_bundlesinserver.product.launch - Restart iDempiere Server: After restart, Jersey will auto-discover the new ResourceExtension during initialization
- Verify deployment: Run
ss tw.idempiere.restin 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
- Create a new
@Path("v1/xxx")resource class intw.idempiere.rest - Add the class to
CalloutResourceExtension.getResourceClasses() - 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ステップ)
- POST
/api/v1/auth/tokens:ユーザー名/パスワードで認証 → 一時トークン+クライアント/ロール一覧を取得 - PUT
/api/v1/auth/tokens:clientId/roleId/orgId を指定 → セッション JWT トークンを取得
トークンは FlutterSecureStorage に保存され、AuthInterceptor を通じて後続のすべてのリクエストに自動付与されます。
ワンステップログイン
すべての ID が既知の場合、parameters オブジェクトに clientId、roleId、organizationId、warehouseId、language を含めて一度の POST でログインできます。
トークン管理
トークンは1時間で失効。リフレッシュトークン(24時間)は一度のみ使用可能 — 再利用するとセキュリティロックが発動します。エンドポイント:POST /api/v1/auth/refresh、POST /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、$skip、label、showlabel。
OData フィルターの注意点
Boolean カラムは引用符なしの true/false を使用します。Char カラムは引用符付きの値を使用します。IsActive eq 'Y' と記述すると、エラーなく誤ったデータが返されるため注意が必要です。
不等号:neq を使用(ne ではない)。全演算子:eq、neq、gt、ge、lt、le、in。文字列関数: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/N(true/false ではない)。
label / showlabel — ラベルフィルタリング
AD_Label でレコードを絞り込み:label=Name eq '%23Customer'(# → %23 URL エンコード)。付与済みラベルの取得:showlabel(詳細情報)または showlabel=Name(名前のみ)。$expand 内でもネストして使用可能。
ドキュメントワークフロー
- POST でヘッダーを作成
- POST で明細行を作成
/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 は使用不可。レスポンス:summary、isError、logs。
インターセプター
| インターセプター | 目的 |
|---|---|
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@ |
本日の日付に置換 |
| 固定値 | Y、0 |
そのまま使用 |
画像フィールド表示(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 によって変更されたフィールドのみが含まれます。変更がない場合は空オブジェクト {} を返します。
サーバー側の処理フロー
- Window/Tab を slug で解決
- GridTab を初期化
- レコードの読み込みまたは新規作成(recordId = 0 の場合は新規)
- 全フィールド値を設定(トリガーフィールドは最後に設定)
gridTab.processFieldChange(triggerField)を実行 — AD_Column.Callout および IColumnCallout を起動- 前後を比較し、changedFields を返却
- 破棄(保存しない)— ステートレス設計、サーバー側のメモリ蓄積なし
デプロイ手順
完全デプロイ4ステップ——カップ麺より簡単(まあ、カップ麺と同じくらい簡単かも):
- Eclipse workspace にインポート:
tw.idempiere.restプロジェクトを Eclipse workspace にインポートします - Launch Config に追加:
server.product.launchのselected_workspace_bundlesにtw.idempiere.rest@default:defaultを追加します - iDempiere Server を再起動:再起動後、Jersey が初期化時に新しい ResourceExtension を自動検出します
- デプロイを確認: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.rest の CalloutResourceImpl にルーティングされます。
新しい REST エンドポイントの追加
tw.idempiere.restに新しい@Path("v1/xxx")リソースクラスを作成CalloutResourceExtension.getResourceClasses()にクラスを追加- iDempiere を再起動 — Jersey が初期化時に自動検出
idempiere-rest コアプラグインの変更は不要です。
Flutter クライアント統合
主要ファイル:field_info.dart(callout プロパティ追加)、window_repository.dart(fireCallout() メソッド追加)、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.dart(callout プロパティ追加)、window_repository.dart(fireCallout() メソッド追加)、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 サーブレット初期化時に一度だけ呼び出されます。