做 ERP 系統夠久的人,一定遇過這個問題:審核流程和單據狀態攪在一起,改一個就壞另一個。
最近在重構一個提案單模組的時候,終於下定決心把審核邏輯從單據狀態裡抽出來。這篇記錄一下為什麼要這樣做,以及實際的架構設計。
問題:一個欄位扛兩件事
我們的提案單原本有兩個狀態欄位:CurrentState 和 PermitState。
CurrentState 管的是單據的生命週期:草稿、生效中、作廢。PermitState 管的是審核流程的進度:待審核、已核准、已駁回。
聽起來分得很清楚對吧?但實際跑起來就不是那麼回事了。
舉個例子:一張提案單被退回了,業務要修改內容重新送審。這時候要怎麼處理?
原本的做法是:把這張單作廢,然後開一張新單。
為什麼要起新號?因為 PermitState 改回「待審核」之後,之前的審核紀錄就被覆蓋了。沒有人知道這張單被退回過、退回的原因是什麼、退回之前的內容長什麼樣。
所以只好起新號,用另一張單重新走流程。
這帶來一堆問題:
- 同一個需求在系統裡有多個單號,追溯困難
- 前一張單被作廢,但它其實不是「無效」,只是「被退回修改」
- 業務要手動把舊單的內容複製到新單,容易出錯
- 報表統計的時候,同一件事被算了好幾次
根因:審核流程不該是單據的屬性
問題的根源在於:我們把「審核進度」當成了單據本身的屬性。
但審核流程本質上是一個獨立的活動。一張單據可以被審核多次(退回重送),每次審核都有自己的版本、自己的評語、自己的結果。這些資訊應該存在獨立的表裡,而不是硬塞在單據欄位裡。
用一個類比:你去銀行申請貸款,被退件要求補資料。銀行不會叫你重新開一個申請案,而是在同一個案件上標記「第二次送審」。原始申請、退件紀錄、補件內容、重新審核,全部串在同一個案號下面。
新架構:ApprovalRecord
重構之後,架構變成這樣:
flowchart TD
A[提案單 Proposal] --> B[ApprovalRecord v1]
A --> C[ApprovalRecord v2]
A --> D[ApprovalRecord v3]
B --> E[審核動作紀錄]
C --> F[審核動作紀錄]
D --> G[審核動作紀錄]
提案單狀態簡化為兩個:Valid / Closed
沒有「待審核」「已核准」「已駁回」了。單據只管自己是不是有效的。
審核邏輯全部搬到 ApprovalRecord 表
每次送審就建一筆 ApprovalRecord,帶版本號。退回重送就版本 +1,同一張單可以有多個版本。
ApprovalRecord 的核心欄位:
ProposalId— 關聯到哪張提案單Version— 第幾次送審(1, 2, 3…)Action— 這次的動作(Submit / Approve / Reject / Cancel)Status— 這個版本目前的狀態(Pending / Approved / Rejected)SnapshotJson— 送審當下的單據內容快照Comment— 審核人的評語CreatedAt/CreatedBy— 誰在什麼時候操作的
退回重送:不起新號
有了 ApprovalRecord 之後,退回重送的流程變成:
- 審核人駁回 → ApprovalRecord v1 的 Status 改為 Rejected,附上退回原因
- 業務修改提案單內容(單據本身還是 Valid,不用作廢)
- 業務重新送審 → 新建 ApprovalRecord v2,SnapshotJson 存修改後的內容
- 審核人核准 → ApprovalRecord v2 的 Status 改為 Approved
全程同一個單號。v1 的退回紀錄永遠保留,包含退回原因和當時的單據快照。任何時候想回顧「這張單被退回幾次、每次退回的原因」,查 ApprovalRecord 表就好。
SnapshotJson:時光機
SnapshotJson 這個欄位值得特別說一下。
每次送審的時候,把當下的單據內容序列化成 JSON 存進去。這樣即使單據後來被修改了,你還是能看到「送審的時候長什麼樣」。
這在實務上非常有用:
- 審計需求:「這張單核准的時候,規格欄寫的是什麼?」→ 直接查 SnapshotJson
- 爭議釐清:「業務說他送審的版本跟現在不一樣」→ 比對 SnapshotJson 跟目前的資料
- 流程追溯:「為什麼 v1 被退回?」→ 看 v1 的 SnapshotJson + Comment
不存快照的話,這些問題都回答不了。因為單據被修改之後,原始內容就消失了。
流程設定:讓審核步驟可配置
光有 ApprovalRecord 還不夠。不同類型的提案單可能走不同的審核流程:
- 業務起單(從需求接收轉過來的):需求接收 → 提案撰寫 → 業務確認 → 立案審核 → 已立案
- 工務自行起單:提案撰寫 → 立案審核 → 已立案
這代表審核的「步驟」也需要是可配置的,不能硬編碼。
所以我另外建了兩張表:
ApprovalFlow(審核流程)
FlowCode— 流程代碼(如RDM_FROM_REQUEST、RDM_DIRECT)Name— 流程名稱Description— 說明
ApprovalFlowStep(審核步驟)
FlowCode— 屬於哪個流程StepOrder— 第幾步StepName— 步驟名稱(如「業務確認」「立案審核」)RoleOrDept— 誰負責這一步
提案單建立的時候,根據起單方式決定走哪個 Flow。每次送審就推進到下一個 Step。退回就退回到指定的 Step(不一定是第一步,有時候只退回上一步)。
這個設計的取捨
優點:
- 單據狀態極度簡單,只管 Valid / Closed
- 審核歷程完整保留,支援多版本
- 退回不起新號,追溯清晰
- SnapshotJson 提供時間點的精確記錄
- 流程步驟可配置,不用改 code 就能調整流程
代價:
- 多了兩三張表,Join 查詢複雜度增加
- SnapshotJson 佔空間(每次送審都存一份完整快照)
- 需要寫 migration 把舊資料搬到新架構
- 前端要配合改,從「讀單據狀態」改成「讀最新的 ApprovalRecord 狀態」
這些代價我覺得都可以接受。空間換時間、複雜度換可維護性,在 ERP 系統裡是很划算的交易。
一些實作細節
如何判斷提案單目前的審核狀態?
1 | -- 取最新版本的 ApprovalRecord |
不是看單據本身的欄位,而是看最新一筆 ApprovalRecord 的 Status。
如何判斷目前走到哪一步?
ApprovalRecord 上記錄 CurrentStep,每次 Approve 就 +1,直到走完所有步驟才變成最終核准。
SnapshotJson 要存多細?
我的做法是存整個 DTO——跟 API 回傳的格式一樣。這樣前端要顯示歷史版本的時候,可以直接用同一套 component render。
結語
審核流程綁在單據狀態上,是 ERP 系統裡很常見的設計債。一開始做的時候不覺得有問題,因為第一版的流程通常很簡單。但隨著業務變複雜——加了退回重送、加了多層審核、加了審計需求——這個設計就開始撐不住了。
如果你現在正在設計新的審核模組,建議一開始就把 ApprovalRecord 抽出來。前期多花兩天建表和寫 API,可以省下後面無數次的「改一個地方壞三個地方」。
如果你跟我一樣是在重構既有系統,那就做好心理準備:migration 會比你想的痛苦。但痛完之後,那個清爽感是值得的。