iT邦幫忙

2026 iThome 鐵人賽

DAY 16
1
Software Development

30天打造一套企業PLM系列 第 16

Day 16:進階搜尋——條件建構器+動態 Predicate

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260902/20161290q6Ro0vpcZD.jpg

系列:30 天打造企業級 PLM|面向:全端

問題場景

「找出上季發行、供應商是 X、狀態不是停產的所有料號。」使用者要的是自己組條件,不想每次都跑來拜託工程師寫 SQL。難點在 Day 4 就埋下了:欄位是 metadata 定義的,有幾百個而且一直在改,搜尋引擎不能認得任何一個具體欄位。今天就透過前端條件建構器與後端動態 Predicate 怎麼合作解掉這題。

進階搜尋實機畫面:

https://ithelp.ithome.com.tw/upload/images/20260902/20161290Q6YvibZP4y.png

商業邏輯設計

  • 搜尋是日常營運動作。採購查供應商料、品保查批號、PM 查案件進度,每個角色都有自己反覆用的查詢,所以有 SavedSearch(條件存起來重放)與 ColumnProfile(同一批結果,不同角色看不同欄位組合)
  • 常用查詢沉澱下來,等於把隱性知識變成資產:老採購「怎麼查料最快」的訣竅,存成 SavedSearch 之後全部門都拿得到

核心內容(一):幾百個欄位如何做到動態查詢

架構演進:從 Agile 私有 Criteria 引擎到 JPA 動態 Predicate

在以前 Oracle Agile PLM 中,進階搜尋(Advanced Search / Parametric Search)依賴的是一套內建於 EJB 核心的私有 Criteria 查詢引擎。

在舊架構下,搜尋條件是透過內部 Attribute ID 拼裝成龐大的專屬 SQL。當查詢條件跨越多個 Page Two / Page Three 動態欄位時,底層 SQL 會膨脹成多層子查詢與外部連接,極易引發 Oracle 資料庫的全表掃描(Full Table Scan);更棘手的是,Agile 的 Saved Search 格式完全封閉在系統內部,外部 REST 用戶端根本無法直接重放或自訂條件樹。

Mini-PLM 的解法是將查詢條件抽象為標準的 JSON 條件樹,後端透過 JPA Criteria API 進行遞迴 Predicate 建構,搜尋引擎對欄位的全部認知只有一筆傳輸格式:

https://ithelp.ithome.com.tw/upload/images/20260902/20161290zSk6fnCHzT.png

這張圖把資料流和設定重用放在同一張圖裡:上半部是一次搜尋如何從條件建構器走到結果,下半部則是同一個 ConfigCriteriaNode 如何再被權限、流程關卡與 SavedSearch 選單引用。這裡最重要的架構邊界有兩個:fieldSource 決定欄位解析要走實體欄位或動態值表;條件樹本身則不綁死求值方式,可以交給資料庫產生 JPA Predicate,也可以在記憶體端轉成 Boolean。如此一來,欄位增加時由 metadata 驅動,權限與流程增加時則重用同一套條件語意。

{ fieldKey, operator, compareValue, fieldSource }

關鍵是 fieldSource。欄位分兩種出身:實體欄位(表單編號、建立日期這種長在資料表欄位上的)與動態欄位(Day 4 存在 key-value 值表裡的)。同一套 Predicate 建構器依 fieldSource 走兩條路,實體欄位直接 root.get(fieldKey);動態欄位則 join 值表,以「key 等於 fieldKey 且 value 符合條件」的 subquery 表達。查詢引擎不認識任何具體欄位,只認識欄位的型態與出身。新欄位加進 metadata,搜尋自動支援,零程式改動。

Operator 集合由欄位型態決定(metadata 又一次當單一事實來源):date 型有 IsToday / EqualTo / Range,select/radio 型有 EQ / NotEq / In / IsNull。前端只渲染合法 operator,後端對每種型態做值轉換,date 字串轉區間、multilist 做 JSON 包含比對。

核心內容(二):條件樹與 Predicate 遞迴

條件支援群組巢狀(AND/OR 括號),後端以遞迴走樹產生 JPA Predicate(CriteriaTreePredicateBuilder 實碼,全文 53 行):

/** 遞迴求單一節點的 Predicate;回傳 null 代表此節點無約束(應被父層略過)。 */
private static Predicate walk(CriteriaTreeNode node, LeafPredicateFn leafFn, CriteriaBuilder cb) {
    if (type != CriteriaNodeTypeEnum.GROUP) {
        return leafFn.toPredicate(node.getItem());   // 葉子:委派欄位級轉換
    }
    List<Predicate> childPreds = new ArrayList<>();
    for (CriteriaTreeNode child : node.getChildren()) {
        Predicate cp = walk(child, leafFn, cb);
        if (cp != null) childPreds.add(cp);          // null=無約束,略過
    }
    if (childPreds.isEmpty()) {
        return cb.conjunction();                     // 群組剪空 → 恆真,不是拋錯
    }
    return node.getItem().getGroupLogical() == LogicalEnum.OR
        ? cb.or(arr) : cb.and(arr);
}

兩個防禦性語意要留意。葉子回 null 表示「無約束」(例如 SavedSearch 的 prompt 欄位使用者沒填值),由父層略過;群組為空回 cb.conjunction()(恆真)而不拋例外——條件全被略過的搜尋應該退化成 match-all,這行為是修過 bug 之後才明確定義的(見下)。

架構上,Form / Item / User / UserGroup 四種搜尋對象共用同一顆樹建構器,各自提供 LeafPredicateFn(欄位怎麼轉 Predicate 因對象而異)。樹的邏輯只寫一次。

踩坑記錄:同一個元件連修四天

前端條件建構器(EditableProTable 行編輯)是全系列修最多輪的元件。四天四個 fix commit,實錄如下:

  1. 首列條件被忽略:扁平模式下 combinePredicate 的預設邏輯是 OR,第一列沒有「與前一列的連接詞」,被當成獨立 OR 分支,單一條件搜尋永遠 match-all。修法:首列強制 AND 語意,空清單語意明確定義為 conjunction
  2. GROUP 節點在 payload 遺失:前端組傳輸格式時只收集 CONDITION 列,括號結構整個掉了,巢狀條件送到後端變成扁平。樹的序列化要連結構節點一起送
  3. 刪列後括號沒正規化:把群組裡最後一列刪掉,留下空括號。後端剪空邏輯救了查詢不炸,但前端顯示殘留空群組。刪列要連帶檢查父群組是否該收斂
  4. 加減按鈕消失:EditableProTable 版本行為變動,行編輯的 actionRender 覆寫掉預設操作鈕。自訂 actionRender 補回

還有一個更根本的:EditableProTable 與 Form 值不同步。行編輯元件包在 Form.Item 裡時,讀與寫都不走 Form 的資料流。儲存前要 mergeLiveFormValues(把表格即時值合併回 Form),載入後要 rowsToFormValues(把資料還原成表格列),雙向橋接缺一不可。進階元件的內部狀態與表單狀態是兩個世界,橋沒搭好就是「畫面上有、存下來沒有」。

核心內容(三):找到之後呢——搜尋結果的下游

搜尋的終點不是「看到結果」,是拿結果去做事。最常見的一件事:把查到的料號夾帶進變更表單(Impacted Items)。Mini-PLM 給了三條動線,成本由低到高排。

拖曳。搜尋結果列、結果追蹤面板(useResultTrackStore,跨查詢收集品項的暫存籃)裡的品項都是拖曳來源,走 Day 14 的 dataTransfer 協定;面板多選之後拖出,payload 自動升級成 ITEM_BATCH,一次夾帶整批。表單端的 AffectedItems 接住 drop,逐筆掛入後彈同款 Drop Summary 總結成敗。

貼上。不是每個使用者都習慣拖曳,更多人手上是一張 Excel 料號清單。批次輸入框直接吃貼上內容,分隔符容忍 ;、換行、Tab、逗號(Excel 各種複製姿勢都涵蓋),一次解析、逐筆加入、總結回報。輸入框還做了一層貼心:正在輸入的最後一段料號達三碼就觸發 debounce 300ms 的自動搜尋補全,單筆輸入與批次貼上共用同一個入口,「輸入一筆即單個加入,輸入多筆即批次加入」。

點擊。傳統的逐筆搜尋加入仍在,當作保底。

三條動線背後是同一個設計判斷:批次動作的失敗要逐筆記帳。無論拖三十筆還是貼三十筆,部分成功是常態(重複、無權限、料號打錯),Summary 列出每一筆失敗與原因,使用者修完清單再貼一次,而不是對著「操作失敗」四個字猜。

順帶一提,搜尋結果欄位組合(ColumnProfile)的欄位順序調整用的是 @dnd-kit/sortable 的拖放排序(ColumnSettingsModal)——同一個產品裡,元件內排序用 dnd-kit、跨頁搬運物件用原生協定,Day 14 定下的分工在這裡再次成立。

核心內容(四):條件不只是查詢——一份條件樹,三種用途

前三節把條件樹當「搜尋語言」講,但它在 Mini-PLM 裡的身分其實是可重用的設定物件ConfigCriteriaNode)。同一顆樹存進資料庫之後,被三個彼此無關的子系統引用,而且全部由管理者在畫面上設定,不需要改程式。

用途一:權限與流程的條件元件

權限(Privilege)可以掛一顆條件樹。AuthorizationService.getMyModifyFields 走訪使用者的權限時,沒掛條件的直接生效;掛了條件的先呼叫 matchFormByCriteria(formId, criteriaNode),這張表單命中條件才把該權限的欄位集合併進來。於是「採購只能改『狀態=草稿』表單的價格欄位」這種需求,不是寫 if-else,而是:建一條「狀態=草稿」的搜尋條件,再把它綁到採購角色的 MODIFY 權限上。Item 權限走同一套(matchItemByCriteria)。

流程關卡ConfigStepCriteria)也是同一個掛法。一個關卡可以掛多組「條件+簽核人/觀察者/通知人/必填欄位」,簽核人解析(StepCriteriaApproverResolver)與必填欄位檢查(ActionService.isCurrentUserMatchedStepCriteria)都先用 matchFormByCriteria 判定這張表單命中哪幾組,再套用該組的名單與必填清單。「金額超過一百萬要加副總簽核、且必須填寫預算科目」——建一條「金額 > 1000000」的條件,綁到關卡上,設定完成。

用途二:存成選單,一鍵重現

搜尋條件儲存後(SavedSearch),可以直接掛到選單。Menu 實體有一個 configCriteriaNode 關聯;管理者在 ConfigMenu 建選單項時選一條 SavedSearch,前端路由就變成 /saved-search/:criteriaNodeId/:labelSavedSearch.tsxcriteriaNodeId 撈回條件樹、依 sourceType(FORM / ITEM / USER / USERGROUP)切換對應的結果元件,然後自動執行。

「我的待簽核 ECO」「本週新開的 Part」「三個月沒登入的帳號」——這些每天要看的清單,過去是每個人自己記條件,現在是左側選單裡的一個項目。條件樹裡有留「prompt 欄位」(值空著、執行時彈窗要使用者填)的話,一條 SavedSearch 就能當參數化報表用:同一個「依供應商查料」選單,點下去先問供應商。Day 4 提到「葉子回 null 表示無約束」的防禦語意,就是替這個場景設計的——prompt 沒填,該條件略過而不是搜尋炸掉。

選單綁的是「條件 ID」而不是「條件內容的複本」。管理者修了 SavedSearch,掛它的每個選單同步生效;同一條件也能掛在不同角色的選單樹上。

用途三:結果欄位自選、存成範本、分享給別人

搜尋結果預設欄位對誰都不對。採購要看單價與供應商,品保要看批號與檢驗狀態,同一批 ECO 兩邊要的欄位完全不重疊。ColumnProfile(欄位範本)解這題:使用者在結果表格的 Column Settings 裡任意勾選欄位、拖拉排序(@dnd-kit/sortable)、調寬度,存成一個具名範本。

範本有兩種 scope:

  • PERSONAL:ownerAccount 是自己,只有自己看得到,隨便建
  • GLOBAL:ownerAccount 為 null,全站可套用,建立與修改需要 MANAGE_GLOBAL_COLUMN_PROFILE 權限

範本依 sourceType 與 formType / itemType 隔離(ECO 的範本不會混進 Part 的清單),且與 SavedSearch 可以綁定:一條 SavedSearch 記住「用哪個欄位範本呈現」(ConfigCriteriaNode.columnProfileId),掛到選單後點開就是「對的條件+對的欄位」,不必每次重新勾。管理者把常用的組合做成 GLOBAL 範本,新進同仁第一天就能套用老手的視角,這是「隱性知識變資產」的另一半。

服務層的一條規則值得記:更新範本只能改名稱與欄位設定,不能改 scope、owner、type。要從個人升成全域,另存一份而不是原地改,否則已引用該範本的 SavedSearch 語意會在使用者不知情下被換掉。

小結

動態查詢能成立,靠的是查詢引擎只認 metadata、不認具體欄位:fieldSource 分流、型態決定 operator、遞迴樹組 Predicate。另一半是實戰磨出來的防禦性語意(null 略過、為空恆真)跟前端狀態橋接。這部分書上不會教,都是修出來的。

再往上一層看,條件樹不只是搜尋語言:它是設定物件,同一顆樹綁到權限就是欄位授權條件、綁到關卡就是簽核路由條件、綁到選單就是一鍵重放的清單,再配上可分享的欄位範本決定「看什麼」。搜尋引擎寫一次,管理者拿它在畫面上組出權限與流程規則,不用再找工程師。

明日 Day 17:前端架構——九個 Zustand store 的切片策略與 routeMap 動態路由。


上一篇
Day 15:Redline——變更差異比對怎麼做
下一篇
Day 17:前端架構——Zustand 切片與前端快取
系列文
30天打造一套企業PLM17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言