本文簡要介紹一個關於單據的常規審核流從雛形到形成標準系統結構的思維轉變, 沒有什麼高深的技術, 有的只是循序漸進的思維轉變.希望能給有類似需求或在軟體設計過程中有困惑的朋友一個簡明參考. ...
本文簡要介紹一個關於單據的常規審核流從雛形到形成標準系統結構的思維轉變, 沒有什麼高深的技術, 有的只是循序漸進的思維轉變.希望能給有類似需求或在軟體設計過程中有困惑的朋友一個簡明參考.
1. 某天, 甲方的採購經理說: 我們的採購申請單需要審核,審核後才能參與下一步流程中.經過簡單考慮於是有了下麵的偽代碼
/// <summary> /// 採購申請單審核 /// </summary> /// <param name="id">單據Id</param> public void PurchaseRequestVerify(string id) { using (var repository = GetRepository<ProjJXCPurchaseRequest>()) { var mainData = repository.Get(id); //獲取採購申請單 //驗證單據是否不存在或狀態不為待審核 mainData.ThrowIfNullOrDelete() .ThrowIf(t => t.Status != BillStatus.VerifyWaite, "審核失敗,只有狀態為[{0}]的單據才可以審核操作", Remarks.GetRemark(BillStatus.VerifyWaite)); //更新狀態 mainData.Status = BillStatus.VerifyOK; mainData.ModUser = AppRuntimes.Instance.CurrentUser.Name; mainData.ModDate = DateTime.Now; repository.Update(mainData); repository.SaveDbChange(); } } /// <summary> /// 保存採購訂單 /// </summary> /// <param name="orderInfo">採購訂單實例</param> public void PurchaseOrderSave(ProjJXCPurchaseOrder orderInfo) { //驗證參數 orderInfo.ThrowIfIsNull() .ThrowIfPropertyIsNullOrEmpty(t => t.FromBillId, "採購申請單"); using (var repository = GetRepository<ProjJXCPurchaseOrder>()) { //獲取採購申請單 var purchaseRequest = repository.Context .ProjJXCPurchaseRequests.Find(orderInfo.FromBillId); purchaseRequest.ThrowIfNullOrDelete() .ThrowIf(t => t.Status != BillStatus.VerifyOK, "所選採購申請單還沒有成功審核"); //..... repository.SaveDbChange(); } }
2. 兩天後, 甲方工程監理同事部說: 工程立項單需要審核, 並且同時需要多個人審核後才能生效.剛要準備開工呢, 路過銷售部時候, 銷售部總經理叫住我說: 我們的銷售計劃需要審核, 並且需要逐級多次審核, 中午吃飯遇上商務部同事:我們的投標保證金申請單支付成功後, 得自動弄個通知告訴我們, 以便我們開展下一步業務......
經過多方位溝通, 看來我們的審核流需要重新進行系統化的設計了, 並希望將其獨立於我們的業務之外, 目前提煉出來的需求包含如下部分;
a). 單據審核人群可以預設,也能支持在提交前選擇需要審核的人群;
b).部分單據需要逐級審核, 部分單據不需要逐級審核,只需要審核人中全部審核通過即可;
c). 單據審核人可以轉發給他人代替自己審核;
d).其中一人審核不通過後,單據直接恢復到新建狀態,並通知提交人需要修改單據並重新提交;
e). 單據在全部審核通過後可以觸發消息通知到指定人員,在審核前,審核後,審核不通過這些節點中可以觸發系類似通知.
f). 用戶需要有自己的待審核列表,並可以直觀的看到單據信息.
針對此功能, 簡單分析如下:
a). 需要一個設定, 主要包括: 標示某個具體的單據是按預設審核流程還是由創建人自行指定審核人; 審核是否需要逐級;審核完成後需要通知的用戶.
b).獲取單據的審核信息的審核, 根據單據類型不同,可能是預設的審核流程,也可能是創建人自行添加的審核人.
c).用戶查看單據的時候, 若單據處於待審核狀態, 判斷當前用戶是否是待審核人, 若是給出審核功能,否則屏蔽審核功能.
d).當單據的全部審核人審核後,更改單據狀態為審核通過.並觸發通知到指定用戶群.
e).用戶在自己的待審核列表中,可以通過在審核信息中冗餘單據詳情地址來直觀的看到單據信息.
f).如何標示單據類型, 有人說可以用枚舉,可是當系統中上百張單據的時候,我想不光定義麻煩,寫switch也能把人寫瘋.在此我們可以借鑒單據編號中的單據定義: 直接使用單據類名來表示BillType.
g). 定義委托集合(或稱之為審核回調方法庫),用於指定當單據審核過程中針對各個節點的對應後續處理.
h). 在BS MVC中,我們直接將審核相關的邏輯集成到 BaseController 和 BaseBusinessService 中, 方便各個單據直接調用.
簡單介紹一下上圖中各對象所表示的含義:
BillCodeDefine : 用於描述單據定義及編號創建規則, 其中 BillType 為單據的類名,參考寫法如 typeOf(ProjJXCPurchaseOrder).Name;
SysBillDefine : 主要用來描述單據審核流, IsVerifySetting 表示是否為預設審核,IsVerifySort 表示為是否按順序審核(亦即前文提到的逐級審核), VerifyDetails表示設定的審核用戶列表,MsgDetails 表示審核成功後需要推送的消息列表.
註意此對象大可和上面的BillCodeDefine 合併, 只不過這個屬於歷史的產物, BillCodeDefine 這個出生在幾年前,已經集成在Framework中,無奈之舉,衍生出了這個SysBillDefine;
VerifyBillUser: 主要用來描述單據的審核用戶列表,其中的DetailUrl 表示具體的單據詳情地址,UserId表示審核用戶,FromId 和NextId分別為審核用戶轉入轉出標識;
SysUserMsg: 用於推送給用戶的消息提醒;
VerifyActionFilter: 用於處理單據審核過程中的各個節點的的回調操作;
VerifyActionFactory: 用於註冊各個單據在審核過程中各個節點的回調方法;
有了以上的基本認識, 我們簡單的介紹下實現過程.
1. 獲取單據的審核定義,
/// <summary> /// 獲取單據審核明細數據 /// </summary> /// <param name="billId">單據Id</param> /// <param name="billType">單據業務</param> /// <returns></returns> public BillVerifyDetail GetVerifyDetails(string billId, string billType) { var rs = new BillVerifyDetail(); if (string.IsNullOrWhiteSpace(billType)) { return rs; } var billDefine = GetRepository<SysBillDefine>().Get(t => t.BillType == billType).FirstOrDefault(); rs.IsVerifySetting = billDefine != null && billDefine.IsVerifySetting; rs.IsVerifySort = billDefine != null && billDefine.IsVerifySort; var verifyUsers = GetVerifyUserListByBillId(billId); if (verifyUsers.IsNullEmpty()) { #region 從單據審核設置中獲取審核配置 List<SysBillVerifyDefine> defines = null; if (rs.IsVerifySetting) { defines = GetVerifyDefineByBillType(billType); } if (!defines.IsNullEmpty()) { verifyUsers = defines.Select(t => new VerifyBillUser() { BillId = billId, BillNum = "", BillType = billType, DepartmentId = t.DepartmentId, DepartmentName = t.DepartmentName, UserId = t.UserId, UserCode = t.UserCode, UserName = t.UserName, VerifyTime = new DateTime(1900, 1, 1), Status = BillStatus.New, VerifySortType = t.VerifySortType, SortIndex = t.SortIndex }).ToList(); } #endregion } rs.VerifyUsers = verifyUsers; return rs; } /// <summary> /// 根據單據id獲取審核明細數據 /// </summary> /// <param name="billId">單據Id</param> /// <param name="verifyStatus">狀態</param> /// <returns></returns> public List<VerifyBillUser> GetVerifyUserListByBillId(string billId, params BillStatus[] verifyStatus) { List<VerifyBillUser> lst = null; if (string.IsNullOrWhiteSpace(billId)) { lst = new List<VerifyBillUser>(); return lst; } var query = GetRepository<VerifyBillUser>().Get(t => t.BillId == billId); if (verifyStatus != null && verifyStatus.Length > 0) { query = query.Where(t => verifyStatus.Contains(t.Status)); } lst = query.OrderBy(t => t.SortIndex).ToList(); return lst; }
2. 審核通過
/// <summary> /// 審核單據 /// </summary> /// <param name="billId">單據Id</param> public void VerifyBillOK(string billId) { billId.ThrowIfIsNull(); AppRuntimes.Instance.CurrentUser.ThrowIfIsNull(); using (var repository = GetRepository<VerifyBillUser>()) { //找尋當前用戶第一條待審核數據 var verifyDetail = repository.Get(t => t.BillId == billId && t.UserId == AppRuntimes.Instance.CurrentUser.Id && t.Status == BillStatus.VerifyWaite).OrderBy(t => t.SortIndex).FirstOrDefault(); if (verifyDetail == null) { throw new BusinessException("親~ 好像沒有你的待審核數據哦,請確認是否已審核或你的上一步審核人已經審核."); } //執行審核前動作 VerifyActionFilter.BeforeVerify(verifyDetail); //通過單據剩餘待審核人數量來判斷當前單據審核後狀態 var waiteVerifyCount = repository.Get(t => t.BillId == billId && (t.Status == BillStatus.VerifyWaite || t.Status == BillStatus.VerifyWaiteLastStep) && t.Id != verifyDetail.Id).Count(); BillStatus mainBillStatus = waiteVerifyCount == 0 ? BillStatus.VerifyOK : BillStatus.VerifyPart; verifyDetail.Status = BillStatus.VerifyOK; verifyDetail.VerifyTime = DateTime.Now; verifyDetail.Remark = string.Empty; repository.Context.Entry(verifyDetail).State = EntityState.Modified; //如果為全部審核通過 if (mainBillStatus == BillStatus.VerifyOK) { //執行全部審核通過後動作 VerifyActionFilter.VerifyFullComplete(verifyDetail); } //如果單據是順序審核,將下一條審核記錄狀態改為待審核 var nextVerifyData = repository.Get(t => t.BillId == billId && t.Status == BillStatus.VerifyWaiteLastStep && t.SortIndex > verifyDetail.SortIndex) .OrderBy(t => t.SortIndex).FirstOrDefault(); if (nextVerifyData != null) { nextVerifyData.Status = BillStatus.VerifyWaite; nextVerifyData.ModDate = DateTime.Now; nextVerifyData.ModUser = AppRuntimes.Instance.CurrentUser.Name; repository.Context.Entry(nextVerifyData).State = EntityState.Modified; } //執行審核後動作 VerifyActionFilter.AfterVerify(verifyDetail); //更新單據狀態 UpdateBillStatus(repository.Context, verifyDetail.BillType, verifyDetail.BillId, mainBillStatus); repository.SaveDbChange(); } }
3. 上圖中有一個方法調用,UpdateBillStatus(repository.Context, verifyDetail.BillType, verifyDetail.BillId, mainBillStatus); 簡明意思就是根據審核情況將對應單據類型指定單據單據狀態更新.這裡有一個小問題是如何通過單據類型找到這個單據的存儲表,或者說如何通過單據類型名稱轉化為對應的DbSet<T>. 沒錯,各位已經想到了通過反射去動態創建DbSet<T>.但因為感覺過於繁瑣,在此我們並沒有如此嘗試,而是通過找到定義在實體對象上的 [Table] 特性去完成的這個動作,參考如下代碼
/// <summary> /// 更新單據狀態 /// </summary> /// <param name="billType">單據類型</param> /// <param name="billId">單據Id</param> /// <param name="billStatus">單據狀態</param> public void UpdateBillStatus(PowerPlantDbContext dbContext, string billType, string billId, BillStatus billStatus) { var tableName = GetBillTypeTableName(billType); var updateSql = string.Format("update {0} set Status={1},ModUser='{2}',ModDate=getdate() where Id='{3}'", tableName, (int)billStatus, AppRuntimes.Instance.CurrentUser.Name, billId); dbContext.Database.ExecuteSqlCommand(updateSql); } /// <summary> /// 緩存實體與資料庫中TableName關係 /// </summary> private static ConcurrentDictionary<string, string> _EntityTableNameDic = new ConcurrentDictionary<string, string>(); /// <summary> /// 根據實體類型名稱獲取資料庫中TableName /// </summary> /// <param name="billType">實體類型名稱</param> /// <returns></returns> private string GetBillTypeTableName(string billType) { return _EntityTableNameDic.GetOrAdd(billType, t => { var tableName = string.Empty; var villEntityAsmStr = string.Format("XZL.Web.Domain.Enties.{0},XZL.Web.Domain.Enties", t); var billEntityType = Type.GetType(villEntityAsmStr); if (billEntityType != null) { var tableAttrs = billEntityType.GetCustomAttributes(typeof(TableAttribute), false); if (tableAttrs != null || tableAttrs.Length == 1) { tableAttrs = billEntityType.GetCustomAttributes(typeof(TableAttribute), true); } if (tableAttrs != null && tableAttrs.Length == 1) { tableName = ((TableAttribute)tableAttrs[0]).Name; } } return tableName; }); }
4. 下麵我們簡單的看下 VerifyActionFilter 及 VerifyActionFactory, 簡單的說就是針對性的定義單據審核執行後續回調方法.參考如下
public class VerifyActionFilter { public static void VerifyFullComplete(VerifyBillUser verifyDetail) { VerifyActionFactory.GetVerifyExecTimeAction(verifyDetail, VerifyMethodExecTime.FullComplete)?.Invoke(verifyDetail); } } public class VerifyActionFactory { private static Dictionary<string, Action<VerifyBillUser>> fullCompleteDictionary; private static object syncLock = new object(); public static Action<VerifyBillUser> GetVerifyExecTimeAction(VerifyBillUser verifyDetail, VerifyMethodExecTime execTime) { Action<VerifyBillUser> action = null; switch (execTime) { case VerifyMethodExecTime.FullComplete: { if (fullCompleteDictionary.ContainsKey(verifyDetail.BillType)) { action = fullCompleteDictionary[verifyDetail.BillType]; } break; } default: break; } return action; } /// <summary> /// 註冊審核回調方法 /// </summary> private static void InitAction() { if (fullCompleteDictionary == null) { lock (syncLock) { if (fullCompleteDictionary == null) { var saleService = new SaleService(); fullCompleteDictionary = new Dictionary<string, Action<VerifyBillUser>>(); fullCompleteDictionary.Add(typeof(SaleProject).Name, saleService.SaleProjectVerifyComplete); //.... } } } } }
5. 經過以上改造後, 我們的整個審核流程貌似就很順暢了, 但是在最後一步, 註冊審核回調方法, 這個常常被很多小伙伴忘記了, 前文也提到了, 我們系統中有上百種單據, 當我們的代碼變成如下截圖的時候,實在是很難排查到底哪個需要設定審核動作的單據沒有設定, 幸好密集恐怖症還算不嚴重, 故此我們需要進一步的優化這個註冊方法.
6. 相比各位或許已經有答案了, 沒錯,我們需要借鑒我們之前經驗, 引入AOP的概念.在需要設定為審核回調的方法上通過"特性"來標註或類似MVC中通過特定的方法名稱來自動實現這個步驟. 然後程式自動識別系統中的這些方法添加到回調方法庫中. 本文簡單介紹通過特性的方式切入.
/// <summary> /// 用於描述審核過程中調用的回調方法 /// </summary> [AttributeUsage(AttributeTargets.Method)] public class VerifyMethodAttribute : Attribute { /// <summary> /// 方法執行時機 /// </summary> public VerifyMethodExecTime ExecTime { get; private set; } /// <summary> /// 執行審核主體對象名稱 /// </summary> public string EntityTypeName { get; private set; } public Type EntityType { get; set; } public VerifyMethodAttribute(VerifyMethodExecTime execTime, Type entityType) { this.EntityType = entityType; this.ExecTime = execTime; this.EntityTypeName = this.EntityType.Name; } } public enum VerifyMethodExecTime { /// <summary> /// 審核前 /// </summary> Before = 1, /// <summary> /// 審核後(只審核成功) /// </summary> After = 2, /// <summary> /// 審核失敗 /// </summary> Fail = 3, /// <summary> /// 全部審核完成 /// </summary> FullComplete = 4 }
7. 改良後的回調函數庫即VerifyActionFactory 中初始化方法,看著順眼多了.
/// <summary> /// 自動初始化 /// </summary> public static void AutoInit() { lock (syncLock) { beforeDictionary = new Dictionary<string, Action<VerifyBillUser>>(); afterDictionary = new Dictionary<string, Action<VerifyBillUser>>(); failDictionary = new Dictionary<string, Action<VerifyBillUser>>(); fullCompleteDictionary = new Dictionary<string, Action<VerifyBillUser>>(); //獲取系統中的服務類型 var svcTypes = Assembly.GetExecutingAssembly().GetTypes() .Where(t => t.BaseType.Equals(typeof(PowerPlantBaseService))).ToList(); if (svcTypes.IsNullEmpty()) { return; } //迭代添加每個服務中的期望方法 svcTypes.ForEach(svc => { var methods = svc.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); if (methods.IsNullEmpty()) { return; } //實例化一個服務 var svcInstance = Activator.CreateInstance(svc); foreach (MethodInfo m in methods) { #region 處理方法,判斷是否有VerifyMethod特性 var verifyMethodFlags = m.GetCustomAttributes(typeof(VerifyMethodAttribute), false); if (verifyMethodFlags.IsNullEmpty()) { continue; } Action<VerifyBillUser> methodAction = null; try { //通過將目標方法轉化為期望的委托 methodAction = Delegate.CreateDelegate(typeof(Action<VerifyBillUser>), m.IsStatic ? null : svcInstance, m) as Action<VerifyBillUser>; } catch (Exception ex) { Log4NetUtil.WriteLog(ex); continue; } if (methodAction == null) { return; } verifyMethodFlags.ForEach(f => { #region 根據特性,對應添加到指定集合中 var temp = (VerifyMethodAttribute)f; if (temp != null) { switch (temp.ExecTime) { case VerifyMethodExecTime.Before: beforeDictionary[temp.EntityTypeName] = methodAction; break; case VerifyMethodExecTime.After: afterDictionary[temp.EntityTypeName] = methodAction; break; case VerifyMethodExecTime.Fail: failDictionary[temp.EntityTypeName] = methodAction; break; case VerifyMethodExecTime.FullComplete: fullCompleteDictionary[temp.EntityTypeName] = methodAction; break; default: break; } } #endregion }); #endregion } }); } }
最後, 附上兩個程式中有關審核的界面截圖作為本文的收尾.
後記, 通過以上對系統中審核流的重新系統設計, 有效的滿足了甲方的需求, 併在可預見的未來,預留了擴能方案.同時也有效簡化了系統中作為開發人員的負擔.使之看是獨立於業務之外運行,又能很好的找到切入點與業務有效的連接.
同時, 理解需求需要多方位多角度,從而能更高角度的去思考.程式的極簡原則並不意味著程式不需要設計,當然本人也十分排斥程式的過度設計.