
系列:30 天打造企業級 PLM|面向:全端
簽核是企業系統的靈魂。一張變更單從送出到放行,中間每一步都要可設定(不同表單走不同流程)、可追溯(誰在哪一關簽了什麼)、可回頭(補件、駁回)。今天講 Mini-PLM 的流程引擎骨架,以及為什麼「回頭路」的設計比「往前走」難得多。
流程設定的實機畫面:
表格模式:
畫布模式:
在以前 Oracle Agile PLM 中,簽核流程引擎是深埋在 EJB 核心中的龐大黑箱。
Agile 的工作流(Workflow & Signoff Sheet)與生命週期狀態(Lifecycle Phase)深度強耦合。持平地說,Agile 的簽核歷程記錄做得很完整——誰、何時、哪一關、什麼意見,Signoff Sheet 上全部留痕,稽核從來不是它的弱項。真正的缺口在群體簽核:簽核對象最終只能展開成一個個「個人」的 Signoff,無法把一個群組當成單一簽核單位、要求「群組內所有人都簽核通過才算完成」,也看不到一個群組層級的整體簽核狀態——想知道這個部門簽到什麼程度,只能自己逐列數人頭。
Mini-PLM 決定採用輕量且解耦的自建狀態機,將流程狀態(FormStatusEnum)、關卡定義與執行實例(Action)徹底分層,並以 workflowRunNo 達成跨週期的隔離。同時補上 Agile 缺的那一塊:關卡簽核人可以直接指派使用者群組,群組內所有成員都簽核通過,該群組的簽核才算完成,且群組本身有整體簽核狀態可以一眼看出進度。

實機畫面:「研發部」以全員簽核(ALL)模式掛在第一關,3 名成員已簽 1 人,面板直接給出 1/3 的比例、進度條與待簽名單——想知道部門簽到什麼程度,不用再逐列數人頭。下方是完整表單頁的視角,簽核進度面板與 Approval History 並列,個人簽核歷程與群組整體狀態各司其職:

評估過引入通用工作流引擎,最後選擇自建。理由有三:PLM 的流程模式其實很收斂,線性關卡加會簽或簽,用不到 BPMN 的任意閘道;通用引擎的流程定義與業務資料是兩個世界,跨界查詢「這張單卡在誰手上」變得昂貴;還有引擎本身的學習與維運成本。自建的前提是需求收斂,若流程真有任意分支合流,自建就是重造輪子。
ConfigWorkflow(流程定義)
└─ ConfigStep(關卡,含順序、簽核模式、進入條件 StepCriteria)
└─ Action(執行期實例:某人在某關的一次簽核任務)
前兩層是設定,管理員畫的流程圖;Action 是執行紀錄,實際執行才產生。表單推進的核心迴圈:進入關卡,initStepActions() 產生該關的 Action(簽核人來源可以是固定名單、Approver Matrix、Trigger 動態指派,Day 10 與 11 展開),收簽核,判定會簽或簽是否滿足,進下一關。
「管理員畫的流程圖」不是比喻。流程設定頁同一頁提供兩種模式(ConfigWorkflowAndSteps.tsx 實碼):
/** 下方區塊顯示模式:table(既有 ConfigSteps 表格)或 canvas(流程畫布) */
export type WorkflowConfigMode = "table" | "canvas";
表格模式適合批次維護欄位,畫布模式(WorkflowFlowCanvas,基於 @xyflow/react)給的是「流程長什麼樣」的直覺:關卡是節點、順序是連線,連線中點有一顆「+」按鈕,點下去就在兩關之間插入新關卡。
畫布選 @xyflow/react 而不是 Day 4 表單設計器用的 @dnd-kit,取捨很清楚:dnd-kit 解的是「排序與放置」,流程圖要的是節點、連線、自訂 edge 與 minimap,那是 node-based editor 的領域,用排序工具硬拼是重造輪子。
架構上最重要的一條約束:畫布是投影,不是真相。節點座標由 orderBy 換算(position: { x: idx * NODE_X_GAP, y: 0 }),不持久化任何畫布版面資料;切到表格模式、或明天有人想再加第三種視圖,資料真相始終只有 ConfigStep。插入關卡時 orderBy 取前後兩關的中間值,間隙不足就全體重排成 10、20、30——跟 BASIC 年代行號留空的概念一致。
畫布也內建兩道防呆。其一是鎖定:啟用中的 workflow 節點呈鎖定狀態(locked),對應後端「啟用中流程不可改結構」的守門
表單可能被退回再送、駁回重啟,同一張表單會有多輪簽核週期。若所有 Action 混在一起,第二輪的「這關簽完了沒」會被第一輪的殘留紀錄污染。解法是 workflowRunNo:每次回頭路都把 run 編號加一,所有判定只看當前 run 的 Action,舊 run 的紀錄完整保留當歷史。這個欄位是後來才加的。沒有它的日子,跨週期的 trigger 誤判與重複加簽(Day 12 的坑)層出不窮。
退回第一關看起來簡單,實際要做七件事,少一件就出錯(FormStatusService.returnToPending() 節錄):
@Transactional
public Form returnToPending(Long formId) {
// 1. 狀態守門:CANCELLED 不可退回
// 2. 權限檢查:RETURN_PENDING privilege(Day 6 的細部權限機制)
boolean hasPrivilege = !authorizationService
.getMyPrivileges(form.getConfigFormType().getCfId(),
PrivilegeEnum.RETURN_PENDING).isEmpty();
// 3. 業務守門:已完成發行且有生效 Affected Items 的表單不可退回,
// 避免已發行資料不一致
if (FormStatusEnum.COMPLETED.name().equals(form.getFormStatus())
&& hasReleasedOrActiveAffectedItems(formId)) {
throw new BusinessException("...cannot return to pending");
}
// 4. 忽略目前所有未完成的 actions,並推進簽核週期
Integer oldRunNo = resolveWorkflowRunNo(form);
actionService.ignoreAllActions(formId);
advanceWorkflowRun(form); // ← runNo +1
// 5. 為第一關初始化新的 Actions(Approver / Observer / Notifier)
actionService.initStepActions(form, firstStep);
// 6. 將表單退回第一關
updateFormCurrStep(form, firstStep);
// 7. 建立 ReturnPending 紀錄(歸屬舊 run,稽核軌跡不斷)
actionService.returnToPending(formId, oldRunNo);
}
有三個細節值得放大。守門在前、變更在後:所有 throw 都發生在任何狀態變更之前,@Transactional 保證原子性,檢查先行讓失敗連 rollback 都不需要。
第 3 條是商業規則:COMPLETED 加已發行 Affected Items 不可退回,因為 Item 已經改版生效了,表單退回會讓「單據說還在審、資料已經發行」兩邊打架。
ReturnPending 紀錄歸舊 run:被退回的紀錄屬於上一輪的歷史,新一輪從乾淨的第一關開始。
returnToPending 早期版本漏了第 5 步 initStepActions。結果退回後表單停在第一關,但第一關沒有任何待簽 Action:狀態顯示 Pending,卻沒有人收到簽核任務,單子直接卡死。使用者的描述是「退回之後單子就不見了」,因為它沒出現在任何人的待辦裡。
對照組是 Reject。駁回原本就有補 initStepActions 的邏輯,退回是後加的 admin 功能,照著 Reject 抄但少抄一行。狀態機的每條轉換路徑都要有完整的出口動作清單,狀態改了、該產生的任務沒產生,是流程引擎最典型的半殘 bug。而且它不會報錯,只會靜靜卡死。
三層模型分離設定與執行,回頭路靠 workflowRunNo 隔離週期,每條轉換路徑要有完整出口動作。不過到目前為止,「誰來簽」還是寫死在關卡設定裡。明日 Day 10:簽核路由與外部整合,讓誰來簽變成資料驅動的決策。