代码改变世界

一个关于单据审核的流程演变

2017-05-19 11:03 by 谢中涞, ... 阅读, ... 评论, 收藏, 编辑

本文简要介绍一个关于单据的常规审核流从雏形到形成标准系统结构的思维转变, 没有什么高深的技术, 有的只是循序渐进的思维转变.希望能给有类似需求或在软件设计过程中有困惑的朋友一个简明参考.

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 中, 方便各个单据直接调用.

我们大致需要以下几个对象来辅助完成这个大业:image

简单介绍一下上图中各对象所表示的含义:

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.  经过以上改造后, 我们的整个审核流程貌似就很顺畅了, 但是在最后一步, 注册审核回调方法, 这个常常被很多小伙伴忘记了, 前文也提到了, 我们系统中有上百种单据, 当我们的代码变成如下截图的时候,实在是很难排查到底哪个需要设定审核动作的单据没有设定, 幸好密集恐怖症还算不严重, 故此我们需要进一步的优化这个注册方法.

image

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
            }
        });
    }
}

1 /// <summary>
2 /// 承包商小项目完工单审核完成调用方法
3 /// </summary>
4 /// <param name="verifyDetail"></param>
5 [VerifyMethod(VerifyMethodExecTime.FullComplete,typeof(CBSProjectComplete))]
6 public void CBSProjectEndVerifyComplete(VerifyBillUser verifyDetail)
7 {
8     //....
9 }

 

 

最后, 附上两个程序中有关审核的界面截图作为本文的收尾.

imageimage

后记, 通过以上对系统中审核流的重新系统设计, 有效的满足了甲方的需求, 并在可预见的未来,预留了扩能方案.同时也有效简化了系统中作为开发人员的负担.使之看是独立于业务之外运行,又能很好的找到切入点与业务有效的连接.

同时, 理解需求需要多方位多角度,从而能更高角度的去思考.程序的极简原则并不意味着程序不需要设计,当然本人也十分排斥程序的过度设计.