MapleCheng

在浩瀚的網路世界中無限潛水欸少年郎!

0%

別再把審核流程綁在單據狀態上了:ApprovalRecord 抽離實戰

做 ERP 系統夠久的人,一定遇過這個問題:審核流程和單據狀態攪在一起,改一個就壞另一個。

最近在重構一個提案單模組的時候,終於下定決心把審核邏輯從單據狀態裡抽出來。這篇記錄一下為什麼要這樣做,以及實際的架構設計。

問題:一個欄位扛兩件事

我們的提案單原本有兩個狀態欄位:CurrentStatePermitState

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 之後,退回重送的流程變成:

  1. 審核人駁回 → ApprovalRecord v1 的 Status 改為 Rejected,附上退回原因
  2. 業務修改提案單內容(單據本身還是 Valid,不用作廢)
  3. 業務重新送審 → 新建 ApprovalRecord v2,SnapshotJson 存修改後的內容
  4. 審核人核准 → ApprovalRecord v2 的 Status 改為 Approved

全程同一個單號。v1 的退回紀錄永遠保留,包含退回原因和當時的單據快照。任何時候想回顧「這張單被退回幾次、每次退回的原因」,查 ApprovalRecord 表就好。

SnapshotJson:時光機

SnapshotJson 這個欄位值得特別說一下。

每次送審的時候,把當下的單據內容序列化成 JSON 存進去。這樣即使單據後來被修改了,你還是能看到「送審的時候長什麼樣」。

這在實務上非常有用:

  • 審計需求:「這張單核准的時候,規格欄寫的是什麼?」→ 直接查 SnapshotJson
  • 爭議釐清:「業務說他送審的版本跟現在不一樣」→ 比對 SnapshotJson 跟目前的資料
  • 流程追溯:「為什麼 v1 被退回?」→ 看 v1 的 SnapshotJson + Comment

不存快照的話,這些問題都回答不了。因為單據被修改之後,原始內容就消失了。

流程設定:讓審核步驟可配置

光有 ApprovalRecord 還不夠。不同類型的提案單可能走不同的審核流程:

  • 業務起單(從需求接收轉過來的):需求接收 → 提案撰寫 → 業務確認 → 立案審核 → 已立案
  • 工務自行起單:提案撰寫 → 立案審核 → 已立案

這代表審核的「步驟」也需要是可配置的,不能硬編碼。

所以我另外建了兩張表:

ApprovalFlow(審核流程)

  • FlowCode — 流程代碼(如 RDM_FROM_REQUESTRDM_DIRECT
  • Name — 流程名稱
  • Description — 說明

ApprovalFlowStep(審核步驟)

  • FlowCode — 屬於哪個流程
  • StepOrder — 第幾步
  • StepName — 步驟名稱(如「業務確認」「立案審核」)
  • RoleOrDept — 誰負責這一步

提案單建立的時候,根據起單方式決定走哪個 Flow。每次送審就推進到下一個 Step。退回就退回到指定的 Step(不一定是第一步,有時候只退回上一步)。

這個設計的取捨

優點:

  • 單據狀態極度簡單,只管 Valid / Closed
  • 審核歷程完整保留,支援多版本
  • 退回不起新號,追溯清晰
  • SnapshotJson 提供時間點的精確記錄
  • 流程步驟可配置,不用改 code 就能調整流程

代價:

  • 多了兩三張表,Join 查詢複雜度增加
  • SnapshotJson 佔空間(每次送審都存一份完整快照)
  • 需要寫 migration 把舊資料搬到新架構
  • 前端要配合改,從「讀單據狀態」改成「讀最新的 ApprovalRecord 狀態」

這些代價我覺得都可以接受。空間換時間、複雜度換可維護性,在 ERP 系統裡是很划算的交易。

一些實作細節

如何判斷提案單目前的審核狀態?

1
2
3
4
5
-- 取最新版本的 ApprovalRecord
SELECT TOP 1 *
FROM X_rdmProposalApproval
WHERE ProposalId = @proposalId
ORDER BY Version DESC

不是看單據本身的欄位,而是看最新一筆 ApprovalRecord 的 Status。

如何判斷目前走到哪一步?

ApprovalRecord 上記錄 CurrentStep,每次 Approve 就 +1,直到走完所有步驟才變成最終核准。

SnapshotJson 要存多細?

我的做法是存整個 DTO——跟 API 回傳的格式一樣。這樣前端要顯示歷史版本的時候,可以直接用同一套 component render。

結語

審核流程綁在單據狀態上,是 ERP 系統裡很常見的設計債。一開始做的時候不覺得有問題,因為第一版的流程通常很簡單。但隨著業務變複雜——加了退回重送、加了多層審核、加了審計需求——這個設計就開始撐不住了。

如果你現在正在設計新的審核模組,建議一開始就把 ApprovalRecord 抽出來。前期多花兩天建表和寫 API,可以省下後面無數次的「改一個地方壞三個地方」。

如果你跟我一樣是在重構既有系統,那就做好心理準備:migration 會比你想的痛苦。但痛完之後,那個清爽感是值得的。