分層架構
iDempiere Mobile 是以 Flutter 打造的 iDempiere ERP 行動客戶端,透過 idempiere-rest 外掛提供的 OData REST API 與伺服器通訊。
應用程式圍繞 8 大業務循環(銷售、採購、財務、庫存、生產、總帳、資產、薪資)以及投資與工作管理,為 iDempiere 標準業務視窗提供增強的行動體驗。
┌──────────────────────────────────────────────┐ │ UI(畫面 / 小工具) │ Flutter widgets, GoRouter ├──────────────────────────────────────────────┤ │ 狀態管理(Riverpod Notifiers) │ StateNotifier, AsyncNotifier ├──────────────────────────────────────────────┤ │ 資料存取層(Repositories) │ 資料取得, OData 查詢 ├──────────────────────────────────────────────┤ │ API 客戶端(Dio) │ HTTP, JWT 認證, 攔截器 ├──────────────────────────────────────────────┤ │ iDempiere REST API │ OData 篩選, 展開, CRUD └──────────────────────────────────────────────┘
資料由上而下流動(UI → 狀態 → Repository → API → iDempiere),狀態通知則透過 Riverpod providers 由下而上傳遞。
目錄結構
lib/
├── main.dart # 進入點, Firebase 初始化
├── app.dart # MaterialApp.router 設定
├── core/
│ ├── api/
│ │ ├── api_client.dart # Dio 客戶端, 通用 CRUD, 使用者可見度
│ │ ├── auth_interceptor.dart # JWT Bearer token + 自動刷新
│ │ ├── context_interceptor.dart # AD_Language / 上下文標頭
│ │ ├── retry_interceptor.dart # 網路錯誤自動重試
│ │ ├── token_storage.dart # 安全 token 持久化
│ │ ├── workflow_api.dart # 核准/駁回/轉發/確認
│ │ ├── attachment_api.dart # 檔案附件
│ │ └── print_api.dart # 列印格式取得
│ ├── config/
│ │ ├── enhanced_module_registry.dart # 選單 → 功能路由對應
│ │ ├── dashboard_module.dart # 儀表板卡片定義
│ │ ├── app_modules.dart # 固定儀表板模組
│ │ ├── branding_config.dart # 白牌設定 (AD_SysConfig)
│ │ └── base_url_storage.dart # 伺服器 URL 持久化
│ ├── constants/
│ │ └── api_constants.dart # API URL 模式, 表格名稱
│ ├── document_framework/ # ★ 設定驅動文件畫面
│ │ ├── config/
│ │ │ ├── document_config.dart # 核心設定模型
│ │ │ ├── field_config.dart # 欄位顯示類型
│ │ │ ├── filter_config.dart # 狀態篩選, 額外篩選
│ │ │ ├── document_registry.dart # 靜態登錄: find(action, targetId)
│ │ │ └── standard_configs.dart # 啟動時註冊所有標準設定
│ │ ├── data/
│ │ │ └── document_repository.dart # 通用 CRUD + OData
│ │ ├── domain/
│ │ │ └── document_list_notifier.dart # 列表狀態管理
│ │ ├── presentation/
│ │ │ ├── document_list_screen.dart # 通用列表畫面
│ │ │ ├── document_detail_screen.dart # 通用詳情畫面
│ │ │ └── widgets/ # 狀態標籤, 資訊列等
│ │ └── document_routes.dart # 從 registry 自動產生路由
│ ├── router/
│ │ └── app_router.dart # GoRouter: 認證轉導, 所有路由
│ ├── services/
│ │ └── fcm_service.dart # Firebase Cloud Messaging
│ └── widgets/ # 共用可重複使用元件
├── features/
│ ├── login/ # 認證流程
│ ├── dashboard/ # 主儀表板
│ ├── sales_order/ # 設定驅動(1 個檔案)
│ ├── purchase_order/ # 設定驅動(1 個檔案)
│ ├── invoice/ # 設定驅動(1 個檔案)
│ ├── payment/ # 設定驅動(1 個檔案)
│ ├── requisition/ # 自訂(表單編輯器)
│ ├── booking/ # 自訂(日曆 UI)
│ ├── attendance_dashboard/ # 自訂(週/月儀表板)
│ ├── approval/ # 工作流程核准
│ ├── chat/ # 即時聊天(WebSocket)
│ ├── report/ # 流程 / 報表檢視器
│ ├── info_window/ # 資訊視窗檢視器
│ ├── window/ # 通用視窗瀏覽器
│ └── ...
└── l10n/
├── app_zh_TW.arb # 範本 ARB(主要)
├── app_en.arb # 英文
├── app_zh.arb # 簡體中文
└── app_ja.arb # 日文
文件畫面框架(Document Screen Framework)
這是應用程式的核心架構模式。它透過「設定取代程式碼」的方式,消除每個功能的重複樣板程式碼。
框架前後對比
| 面向 | 傳統方式(手動撰寫) | 框架方式(設定驅動) |
|---|---|---|
| 每個功能的檔案數 | 6+ 個(model, repo, notifier, list, detail, routes) | 1 個設定檔 |
| 每個功能的程式行數 | ~500 行 | ~50 行 |
| 新增一個功能 | 複製貼上 + 修改 6 個檔案 | 寫 1 個設定 + 2 個 l10n key |
| 維護修復 | 在 30 個地方修正 bug | 在框架中修正一次 |
運作原理
1. 設定 (Config) 2. 登錄 (Registry) 3. 畫面 (Screens)
┌─────────────────┐ ┌───────────────────┐ ┌──────────────────────┐
│ DocumentConfig │───►│ DocumentRegistry │───►│ DocumentListScreen │
│ - tableName │ │ find(action, id) │ │ DocumentDetailScreen │
│ - fields │ │ register(config) │ │ (從 config 讀取) │
│ - filters │ └───────────────────┘ └──────────────────────┘
│ - actions │ │
│ - routes │ ▼
└─────────────────┘ ┌───────────────────────────┐
│ generateDocumentRoutes() │
│ → 每個 config 產生 GoRoute │
└───────────────────────────┘
- Config(
DocumentConfig)— 宣告所有資訊:表格名稱、欄位、篩選、動作、路由 - Registry(
DocumentRegistry)— 儲存所有 config,以(action, targetId)查詢 - 通用畫面(
DocumentListScreen、DocumentDetailScreen)— 根據 config 渲染 - 自動路由(
generateDocumentRoutes())— 從所有已註冊的 config 建立 GoRoute
Config 註冊流程
// 在應用程式啟動時(main.dart):
registerStandardConfigs(); // 註冊 23 個 DocumentConfig
// 每個 config 是一個頂層變數:
// lib/features/sales_order/sales_order_config.dart
final salesOrderConfig = DocumentConfig(
targetId: 143, // AD_Window_ID
tableName: 'C_Order',
title: (l10n) => l10n.salesOrderTitle,
...
);
選單解析流程
當使用者點擊 iDempiere 選單項目時,應用程式依序解析要顯示哪個畫面:
使用者點擊選單項目 (action='W', targetId=143)
│
▼
findEnhancedModule(action, targetId)
│
├──► 1. DocumentRegistry.find() → 設定驅動畫面(23 個功能)
│
├──► 2. enhancedModuleRegistry[] → 舊版自訂畫面(7 個功能)
│
└──► 3. 通用視窗瀏覽器 → 未對應視窗的後備方案
設定驅動 vs 自訂功能
| 面向 | 設定驅動 | 自訂功能 |
|---|---|---|
| 檔案數 | 1 個設定檔(~50 行) | 6+ 個檔案(data/domain/presentation) |
| 適用場景 | 標準 列表 → 詳情 → 動作 | 表單、日曆、儀表板、複雜 UI |
| 數量 | 23 個功能 | ~15 個功能 |
| 範例 | 銷售訂單、發票、產品 | 請購單、預約、聊天、核准 |
通用視窗的 Slug 解析
通用視窗模組(/dashboard/window/:slug/:recordId)使用 slug(URL 友善名稱)來識別視窗,而非數字的 AD_Window_ID。WindowRepository.resolveSlug() 處理兩種格式:
- 非數字字串 → 視為 slug,直接回傳
- 數字字串 → 先嘗試作為
AD_Window_ID查詢,再作為數字 slug 的後備方案
這種兩步驟解析之所以必要,是因為某些 iDempiere 視窗擁有純數字的 slug(例如 "7005"),否則會被誤判為 Window ID。簽核模組的「跳轉至原始文件」功能使用 resolveWindowSlugForTable(tableName),透過 AD_Table → AD_Tab → AD_Window_ID → slug 的鏈路解析後,再以 slug 進行導航。
狀態管理
Riverpod Providers
apiClientProvider— 單例 Dio 客戶端,含認證攔截器tokenStorageProvider— JWT token 持久化brandingConfigProvider— 伺服器端品牌設定(FutureProvider)- 設定驅動功能 — 每個 config 一個
DocumentListNotifier(auto-dispose) - 自訂功能 — 各功能自有 StateNotifier / AsyncNotifier
列表畫面模式
DocumentListNotifier (StateNotifier<DocumentListState>)
├── records: List<Map<String, dynamic>>
├── isLoading: bool
├── filter: String (搜尋文字)
├── statusFilter: String (DocStatus 篩選標籤)
├── extraFilters: Map (額外篩選)
├── page / hasMore (分頁)
└── methods: refresh(), fetchNextPage(), setFilter(), setStatusFilter()
詳情畫面模式
FutureProvider.autoDispose<Map<String, dynamic>> // 標頭記錄
FutureProvider.autoDispose<List<Map>> // 明細行項目
路由
使用 GoRouter,在 /dashboard 下使用巢狀路由:
/login → LoginScreen
/dashboard → DashboardScreen (ShellRoute)
/dashboard/sales-order → DocumentListScreen(自動產生)
/dashboard/sales-order/:id → DocumentDetailScreen(自動產生)
/dashboard/requisition → RequisitionListScreen(手動)
/dashboard/requisition/new → RequisitionFormScreen(手動)
/dashboard/booking → BookingScreen(手動)
/dashboard/window/:id → GenericWindowScreen(後備方案)
設定驅動的路由由 generateDocumentRoutes() 自動產生。自訂功能則在 app_router.dart 中手動註冊路由。
權限模型
權限完全由伺服器驅動 — Flutter 應用程式不執行存取控制。
iDempiere 伺服器
└── 角色 (Role)
├── AD_Window_Access → 角色可存取的視窗
├── AD_Process_Access → 可執行的流程/報表
└── AD_Form_Access → 可使用的表單
│
▼
選單樹 API (/api/v1/menus/{treeId})
│
▼
Flutter 僅接收已授權的選單項目
│
▼
findEnhancedModule() 對應到 Flutter 路由
應用程式只顯示伺服器篩選後的選單樹所回傳的項目,不需要客戶端的權限檢查。
本地化
支援 4 種語言:zh_TW(範本)、en、zh、ja。
- 範本 ARB:
lib/l10n/app_zh_TW.arb— 先在此新增 key - 產生的類別:
S(AppLocalizations的別名) - 使用方式:
S.of(context).myKey或l10n.myKey - 設定驅動功能使用延遲求值:
title: (l10n) => l10n.featureTitle
關鍵設計決策
為何用設定驅動而非程式碼產生?
Config 物件是執行時期值 — 無需建置步驟、無產生的檔案、可立即 hot reload。開發者新增一個檔案即可讓功能運作。程式碼產生會增加複雜度(build_runner、模板維護),卻得到相同的結果。
為何使用 Map<String, dynamic> 而非型別化模型?
iDempiere 有 800+ 張表格。為每張表格產生 Freezed 模型並不實際。通用的 Map<String, dynamic> 搭配 DisplayField.resolve() 能統一處理所有表格。$expand 指令回傳含有 id、identifier、model-name 的巢狀 Map — DisplayField.lookup() 會自動處理。
為何選擇 Riverpod 而非 Bloc/Provider?
Riverpod 提供:auto-dispose(導航時無記憶體洩漏)、ref.watch 實現響應式更新、family providers 支援參數化、以及 repository 中無需 BuildContext 依賴。
Freezed sealed class(不可變模型)
認證狀態使用 Freezed sealed class(AuthState),啟用 4 種認證狀態(unauthenticated、loading、roleSelection、authenticated)的窮盡模式匹配。編譯器會強制處理所有狀態分支,避免遺漏。
Repository 模式
Repository 封裝所有 API 呼叫與資料映射,讓 Notifier 專注於業務邏輯。這使得單元測試可以輕鬆 mock Repository,不需啟動 HTTP 伺服器。
通用 ApiClient
單一 API 客戶端透過表格名稱處理所有 iDempiere CRUD 操作,避免為每個實體建立獨立的 endpoint 類別。搭配 OData 篩選語法,一個方法即可查詢任何表格。
ODataFilter / ODataExpand 建構器
型別安全的建構器在編譯期強制正確的 OData 篩選語法。boolEq() 處理布林欄位(避免 IsActive eq 'Y' 錯誤)、neq() 強制正確運算子(iDempiere 使用 neq 而非標準 OData 的 ne)。單引號自動轉義。
M_Movement 單據工作流程
入庫與出庫均使用相同的 3 步驟模式(建立表頭 → 建立明細行 → 以 doc-action: CO 完成),對應 iDempiere 的標準單據處理流程。這確保了業務邏輯的一致性,也簡化了程式碼結構。
為何有 enhanced_module_registry 和 DocumentRegistry 兩套系統?
這是歷史遷移路徑留下的產物——說白了就是「祖宗之法不可廢,但可以慢慢架空」。enhancedModuleRegistry 是最早的靜態 Map,負責把選單項目對應到自訂功能畫面。後來引入了更強大的 DocumentRegistry,以設定驅動的方式處理標準功能。
findEnhancedModule() 的查詢順序是:先問 DocumentRegistry,再問舊版 Map。這意味著只要把功能的 config 註冊到 DocumentRegistry,舊版 Map 裡的對應條目就自動失效——漸進式遷移,不需要一次大爆炸重構。最終目標是讓舊版 Map 只剩下那些真正需要自訂 UI 的功能(表單編輯器、日曆、儀表板等),標準的列表→詳情流程全部由 DocumentRegistry 接管。
Slug 生成邏輯(Server 端)
iDempiere REST plugin 使用 TypeConverterUtils.slugify() 將 Window/Tab/Process 名稱轉為 URL-friendly slug。關鍵:slug 來源永遠是英文原始名稱,跟使用者登入的語系完全無關。(所以別問「為什麼我的中文視窗名稱變成空的」——這不是 bug,這是 feature。)
// 呼叫 window.getName() → get_Value("Name") → 讀取 AD_Window.Name 欄位
// 不是 get_Translation("Name"),所以永遠是英文原始名稱
jsonObject.addProperty("slug", TypeConverterUtils.slugify(window.getName()));
Slugify 演算法(5 步驟):
- 空白與標點符號 → 替換為
- - Unicode NFD 正規化(拆解重音字母,如
é→e+ combining accent) - 移除所有非 ASCII 字母數字(
[^\w_-]使用 ASCII 模式) - 全部轉小寫
- 合併重複的
-
| AD_Window.Name | Slug | 說明 |
|---|---|---|
Purchase Order |
purchase-order |
正常英文名稱,空白變 - |
Sales Order |
sales-order |
正常英文名稱 |
Éléments |
elements |
重音字母被 NFD 拆解後保留 ASCII 部分 |
採購訂單 |
""(空字串) |
中文字全被 step 3 移除——恭喜你踩坑了 |
注意事項:
getName()讀取的是AD_Window.Name(英文原始欄位),不是AD_Window_Trl.Name(翻譯欄位),所以 slug 永遠從英文名稱生成。- 如果你的 iDempiere 有 custom window 且
AD_Window.Name用中文命名(沒有走翻譯表),slug 會變成空字串,REST API 呼叫就會爆炸。 - 最佳實踐:自訂 Window/Tab/Process 的
Name欄位務必使用英文,中文名稱放在AD_Window_Trl翻譯表。這是鐵律,不是建議。
🌐 English Version
Layer Architecture
iDempiere Flutter is a mobile client for iDempiere ERP. It connects to iDempiere via the idempiere-rest plugin’s OData-based REST API.
The app provides enhanced mobile screens for iDempiere’s standard business windows, organized around the 8 business cycles (Sales, Purchasing, Financing, Inventory, Production, GL, Assets, Payroll) plus Investment and Work Management.
┌──────────────────────────────────────────────┐
│ UI (Screens/Widgets) │ Flutter widgets, GoRouter
├──────────────────────────────────────────────┤
│ State (Riverpod Notifiers) │ StateNotifier, AsyncNotifier
├──────────────────────────────────────────────┤
│ Repositories │ Data fetching, OData queries
├──────────────────────────────────────────────┤
│ API Client (Dio) │ HTTP, JWT auth, interceptors
├──────────────────────────────────────────────┤
│ iDempiere REST API │ OData filters, expand, CRUD
└──────────────────────────────────────────────┘
Data flows downward (UI → State → Repository → API → iDempiere). State notifications flow upward via Riverpod providers.
Document Screen Framework
The framework is the core architectural pattern. It eliminates per-feature boilerplate by using configuration instead of code.
| Aspect | Before (hand-coded) | After (config-driven) |
|---|---|---|
| Files per feature | 6+ (model, repo, notifier, list, detail, routes) | 1 config file |
| Lines per feature | ~500 | ~50 |
| Adding a feature | Copy-paste + modify 6 files | Write 1 config + 2 l10n keys |
| Maintenance | Fix bugs in 30 places | Fix once in framework |
How It Works
- Config (
DocumentConfig) declares everything: table name, fields, filters, actions, routes - Registry (
DocumentRegistry) stores configs, looked up by(action, targetId) - Generic screens (
DocumentListScreen,DocumentDetailScreen) render from config - Auto-routing (
generateDocumentRoutes()) creates GoRoutes from all registered configs
Menu Resolution
When a user taps an iDempiere menu item, the app resolves which screen to show:
DocumentRegistry.find()→ Config-driven screen (23 features)enhancedModuleRegistry[]→ Legacy custom screen (7 features)- Generic Window browser → Fallback for unmapped windows
Slug Resolution for Generic Window
The Generic Window module (/dashboard/window/:slug/:recordId) uses slugs (URL-friendly names) to identify windows, not numeric AD_Window_ID values. WindowRepository.resolveSlug() handles both formats:
- Non-numeric string → treated as slug, returned as-is
- Numeric string → first tried as
AD_Window_IDlookup, then as numeric slug fallback
This two-step resolution is necessary because some iDempiere windows have purely numeric slugs (e.g. "7005") that would otherwise be misidentified as window IDs. The approval module’s “zoom to source document” feature uses resolveWindowSlugForTable(tableName) which resolves AD_Table → AD_Tab → AD_Window_ID → slug, then navigates via slug.
State Management
apiClientProvider— singleton Dio client with auth interceptorstokenStorageProvider— JWT token persistencebrandingConfigProvider— server-side branding (FutureProvider)- Config-driven features use
DocumentListNotifierper config (auto-dispose) - Custom features use per-feature StateNotifier/AsyncNotifier
Routing
GoRouter with nested routes under /dashboard. Config-driven routes are auto-generated by generateDocumentRoutes(). Custom features register routes manually in app_router.dart.
Permission Model
Permissions are server-driven — the Flutter app does not enforce access control. The app only shows menu items that the server’s filtered Menu Tree returns.
Localization
4 languages: zh_TW (template), en, zh, ja. Template ARB: lib/l10n/app_zh_TW.arb. Config-driven features use title: (l10n) => l10n.featureTitle for lazy evaluation.
Why enhanced_module_registry + DocumentRegistry (Two Systems)?
Historical migration path. enhancedModuleRegistry is the original static map for routing menu items to custom feature screens. DocumentRegistry is the newer, config-driven system for standard features.
findEnhancedModule() checks DocumentRegistry first, then falls back to the legacy map. This means once a feature’s config is registered in DocumentRegistry, the legacy map entry is automatically bypassed — enabling gradual migration without a big-bang refactor. The end goal: the legacy map will only contain features that genuinely need custom UI (form editors, calendars, dashboards), while all standard list→detail flows are handled by DocumentRegistry.
Slug Generation Logic (Server Side)
The iDempiere REST plugin uses TypeConverterUtils.slugify() to convert Window/Tab/Process names into URL-friendly slugs. Key point: slugs are always generated from the English base name, regardless of the user’s login locale.
// Calls window.getName() → get_Value("Name") → reads AD_Window.Name field
// NOT get_Translation("Name"), so it's always the English base name
jsonObject.addProperty("slug", TypeConverterUtils.slugify(window.getName()));
Slugify Algorithm (5 steps):
- Whitespace and punctuation → replaced with
- - Unicode NFD normalization (decompose accented characters, e.g.
é→e+ combining accent) - Remove all non-ASCII alphanumerics (
[^\w_-]in ASCII mode) - Convert to lowercase
- Collapse consecutive
-
| AD_Window.Name | Slug | Notes |
|---|---|---|
Purchase Order |
purchase-order |
Normal English name, spaces become - |
Sales Order |
sales-order |
Normal English name |
Éléments |
elements |
Accented chars decomposed via NFD, ASCII part retained |
採購訂單 |
"" (empty string) |
CJK characters all removed by step 3 |
Important Notes:
getName()readsAD_Window.Name(the English base field), notAD_Window_Trl.Name(translation field), so slugs are always generated from English names.- If your iDempiere instance has custom windows with Chinese
AD_Window.Name(instead of English name + translation viaAD_Window_Trl), the slug becomes an empty string and REST API calls will fail. - Best Practice: Always use English for custom Window/Tab/Process
Namefields. Put Chinese names in theAD_Window_Trltranslation table.
🇯🇵 日本語版
レイヤーアーキテクチャ
iDempiere Flutter は iDempiere ERP のモバイルクライアントです。idempiere-rest プラグインの OData ベース REST API を通じて iDempiere と通信します。
本アプリは、8つの業務サイクル(販売、購買、財務、在庫、製造、総勘定元帳、資産、給与)に加え、投資および作業管理について、iDempiere の標準業務ウィンドウに対する強化されたモバイル画面を提供します。
┌──────────────────────────────────────────────┐
│ UI(画面 / ウィジェット) │ Flutter widgets, GoRouter
├──────────────────────────────────────────────┤
│ 状態管理(Riverpod Notifiers) │ StateNotifier, AsyncNotifier
├──────────────────────────────────────────────┤
│ リポジトリ層 │ データ取得, OData クエリ
├──────────────────────────────────────────────┤
│ API クライアント(Dio) │ HTTP, JWT 認証, インターセプター
├──────────────────────────────────────────────┤
│ iDempiere REST API │ OData フィルタ, 展開, CRUD
└──────────────────────────────────────────────┘
データは上から下へ流れます(UI → 状態 → リポジトリ → API → iDempiere)。状態通知は Riverpod プロバイダーを通じて下から上へ伝達されます。
Document Screen Framework
このフレームワークは、アプリのコアとなるアーキテクチャパターンです。コードの代わりに設定を使用することで、機能ごとの重複した定型コードを排除します。
| 観点 | 従来方式(手書き) | フレームワーク方式(設定駆動) |
|---|---|---|
| 機能あたりのファイル数 | 6個以上(model, repo, notifier, list, detail, routes) | 設定ファイル1個 |
| 機能あたりのコード行数 | 約500行 | 約50行 |
| 機能の追加 | コピー&ペースト+6ファイルを修正 | 設定1個+l10n キー2個を記述 |
| メンテナンス | 30箇所でバグ修正 | フレームワーク内で1回修正 |
動作の仕組み
- Config(
DocumentConfig)がすべてを宣言:テーブル名、フィールド、フィルター、アクション、ルート - Registry(
DocumentRegistry)が設定を保存し、(action, targetId)で検索 - 汎用画面(
DocumentListScreen、DocumentDetailScreen)が設定に基づいてレンダリング - 自動ルーティング(
generateDocumentRoutes())が登録済みの全設定から GoRoute を生成
メニュー解決
ユーザーが iDempiere のメニュー項目をタップすると、アプリは表示する画面を以下の順序で解決します。
DocumentRegistry.find()→ 設定駆動画面(23機能)enhancedModuleRegistry[]→ レガシーカスタム画面(7機能)- 汎用ウィンドウブラウザ → マッピングされていないウィンドウのフォールバック
汎用ウィンドウの Slug 解決
汎用ウィンドウモジュール(/dashboard/window/:slug/:recordId)は、数値の AD_Window_ID ではなく slug(URL フレンドリーな名前)を使用してウィンドウを識別します。WindowRepository.resolveSlug() は両方の形式を処理します:
- 非数値文字列 → slug として扱い、そのまま返却
- 数値文字列 → まず
AD_Window_IDとして検索し、次に数値 slug のフォールバックとして試行
この2段階の解決が必要な理由は、一部の iDempiere ウィンドウが純粋な数値の slug(例:"7005")を持っており、そのままではウィンドウ ID と誤認されるためです。承認モジュールの「元の伝票にジャンプ」機能は resolveWindowSlugForTable(tableName) を使用し、AD_Table → AD_Tab → AD_Window_ID → slug のチェーンで解決後、slug を使って画面遷移します。
状態管理
apiClientProvider— 認証インターセプター付きのシングルトン Dio クライアントtokenStorageProvider— JWT トークンの永続化brandingConfigProvider— サーバー側ブランディング設定(FutureProvider)- 設定駆動機能は設定ごとに
DocumentListNotifierを使用(auto-dispose) - カスタム機能は機能ごとの StateNotifier / AsyncNotifier を使用
ルーティング
/dashboard 配下にネストされたルートを持つ GoRouter を使用。設定駆動ルートは generateDocumentRoutes() により自動生成されます。カスタム機能は app_router.dart で手動でルートを登録します。
権限モデル
権限はサーバー駆動です。Flutter アプリはアクセス制御を行いません。アプリは、サーバーのフィルタリング済みメニューツリーが返す項目のみを表示します。
ローカライゼーション
4言語対応:zh_TW(テンプレート)、en、zh、ja。テンプレート ARB:lib/l10n/app_zh_TW.arb。設定駆動機能は遅延評価のために title: (l10n) => l10n.featureTitle を使用します。
enhanced_module_registry と DocumentRegistry の二重システムの理由
歴史的な移行パスの結果です。enhancedModuleRegistry はメニュー項目をカスタム機能画面にルーティングするための元々の静的 Map です。DocumentRegistry は標準機能向けの、より新しい設定駆動システムです。
findEnhancedModule() はまず DocumentRegistry を確認し、次にレガシー Map にフォールバックします。つまり、機能の設定を DocumentRegistry に登録すれば、レガシー Map のエントリは自動的にバイパスされます。これにより、大規模なリファクタリングなしで段階的な移行が可能になります。最終目標:レガシー Map には本当にカスタム UI が必要な機能(フォームエディタ、カレンダー、ダッシュボードなど)のみを残し、標準的な一覧→詳細フローはすべて DocumentRegistry が処理します。
Slug 生成ロジック(サーバー側)
iDempiere REST プラグインは TypeConverterUtils.slugify() を使用して Window/Tab/Process の名前を URL フレンドリーな slug に変換します。重要:slug は常に英語の元の名前から生成され、ユーザーのログインロケールとは無関係です。
// window.getName() → get_Value("Name") → AD_Window.Name フィールドを読み取り
// get_Translation("Name") ではないため、常に英語の元の名前
jsonObject.addProperty("slug", TypeConverterUtils.slugify(window.getName()));
Slugify アルゴリズム(5ステップ):
- 空白と句読点 →
-に置換 - Unicode NFD 正規化(アクセント文字を分解、例:
é→e+ combining accent) - 非 ASCII 英数字をすべて除去(
[^\w_-]ASCII モード) - すべて小文字に変換
- 連続する
-を統合
| AD_Window.Name | Slug | 説明 |
|---|---|---|
Purchase Order |
purchase-order |
通常の英語名、空白が - に変換 |
Sales Order |
sales-order |
通常の英語名 |
Éléments |
elements |
アクセント文字が NFD で分解後、ASCII 部分を保持 |
採購訂單 |
""(空文字列) |
CJK 文字はステップ3ですべて除去 |
注意事項:
getName()はAD_Window.Name(英語の元のフィールド)を読み取り、AD_Window_Trl.Name(翻訳フィールド)ではありません。そのため slug は常に英語名から生成されます。- iDempiere インスタンスに中国語の
AD_Window.Nameを持つカスタムウィンドウがある場合(英語名+AD_Window_Trl経由の翻訳ではなく)、slug は空文字列になり、REST API 呼び出しが失敗します。 - ベストプラクティス:カスタム Window/Tab/Process の
Nameフィールドは必ず英語を使用し、中国語名はAD_Window_Trl翻訳テーブルに格納してください。