优化审批功能
This commit is contained in:
1 parent
184d5b0403
commit
395c7f717e
15 files changed
+2997
-16
No files matched your search
@@ -0,0 +1,726 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
approvalEngine "server/services/approval"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
)
|
||||
|
||||
// 移动端审批接口,挂在 AppOaController 上。
|
||||
//
|
||||
// 设计说明:
|
||||
// - 数据按 JWT 的 tid + user 双重隔离:发起列表仅看本人发起的审批,
|
||||
// 待办列表仅看本人作为审批人的待办;
|
||||
// - 复用 services/approval 引擎的 Submit / Approve,保证与 Web 端行为一致;
|
||||
// - 单据类型(biz_type)取审批流 yz_backend_approval_flow.biz_type;
|
||||
// 移动端不开放自由单据类型,仅允许本租户已配置的分类。
|
||||
//
|
||||
// 路由前缀:/api/oa/approval/*
|
||||
//
|
||||
// 接口清单:
|
||||
// GET /api/oa/approval/list 我发起的审批实例
|
||||
// GET /api/oa/approval/todo 我的待办(作为审批人)
|
||||
// GET /api/oa/approval/detail/:id 审批详情(含任务时间线)
|
||||
// POST /api/oa/approval/submit 提交审批
|
||||
// POST /api/oa/approval/approve 通过/驳回审批任务
|
||||
// POST /api/oa/approval/withdraw/:id 发起人撤回审批
|
||||
// GET /api/oa/approval/flows 可发起的流程清单(按分类)
|
||||
// GET /api/oa/approval/categories 单据类型(biz_type)下拉
|
||||
|
||||
// ============================ 入参 ============================
|
||||
|
||||
// oaApprovalSubmitPayload 提交审批入参
|
||||
type oaApprovalSubmitPayload struct {
|
||||
BizType string `json:"biz_type"` // 单据类型(必填)
|
||||
FlowCode string `json:"flow_code"` // 指定流程(可选;留空取该 biz_type 默认流)
|
||||
FlowID uint64 `json:"flow_id"` // 指定流程ID(可选)
|
||||
BizTitle string `json:"biz_title"` // 单据标题(必填)
|
||||
BizID uint64 `json:"biz_id"` // 关联业务单据ID(可选)
|
||||
FormData map[string]interface{} `json:"form_data"` // 表单数据
|
||||
}
|
||||
|
||||
// oaApprovalApprovePayload 审批入参
|
||||
type oaApprovalApprovePayload struct {
|
||||
TaskID uint64 `json:"task_id"` // 待审批任务ID(必填)
|
||||
Pass bool `json:"pass"` // true 通过 false 驳回
|
||||
Comment string `json:"comment"` // 审批意见
|
||||
}
|
||||
|
||||
// ============================ 工具 ============================
|
||||
|
||||
// oaApprovalIDParam 解析 :id 路由参数
|
||||
func oaApprovalIDParam(c *AppOaController) uint64 {
|
||||
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// oaApprovalClaims 解析并校验 JWT
|
||||
func oaApprovalClaims(c *AppOaController) (*jwtutil.Claims, bool) {
|
||||
claims, err := c.oaClaims()
|
||||
if err != nil {
|
||||
c.oaErr(401, 401, "未登录或无权限")
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
// oaApprovalEmployee 通过 JWT 找到当前登录用户对应的员工记录(用于取部门 ID)
|
||||
func oaApprovalEmployee(c *AppOaController, claims *jwtutil.Claims) *models.BackendEmployee {
|
||||
emp, ok := c.oaMyEmployee(claims)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return emp
|
||||
}
|
||||
|
||||
// oaApprovalSubmitUID 通过 JWT 拿到租户侧的 UID(SystemTenantUser.id),
|
||||
// 审批引擎 SubmitInput.SubmitUID 用的是租户侧用户 ID 而非平台 AdminUser.ID。
|
||||
func oaApprovalSubmitUID(claims *jwtutil.Claims) uint64 {
|
||||
return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
|
||||
}
|
||||
|
||||
// oaApprovalReadJSON 读取并解析 JSON 请求体,失败时输出错误响应
|
||||
func oaApprovalReadJSON(c *AppOaController, dst interface{}) bool {
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil {
|
||||
c.oaErr(400, 400, "参数错误")
|
||||
return false
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return true
|
||||
}
|
||||
if err := json.Unmarshal(raw, dst); err != nil {
|
||||
c.oaErr(400, 400, "参数错误: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// oaApprovalUserName 取得发起人姓名(优先员工表,否则使用 JWT 用户名)
|
||||
func oaApprovalUserName(c *AppOaController, claims *jwtutil.Claims) string {
|
||||
if emp := oaApprovalEmployee(c, claims); emp != nil && emp.Name != "" {
|
||||
return emp.Name
|
||||
}
|
||||
return strings.TrimSpace(claims.Username)
|
||||
}
|
||||
|
||||
// ============================ 实例组装 ============================
|
||||
|
||||
// oaApprovalStatusText / oaApprovalStatusTag 状态展示文案/Tag 类型(uview-plus)
|
||||
func oaApprovalStatusText(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalInstanceDraft:
|
||||
return "草稿"
|
||||
case models.ApprovalInstanceRunning:
|
||||
return "审批中"
|
||||
case models.ApprovalInstanceApproved:
|
||||
return "已通过"
|
||||
case models.ApprovalInstanceRejected:
|
||||
return "已驳回"
|
||||
case models.ApprovalInstanceWithdrawn:
|
||||
return "已撤回"
|
||||
case models.ApprovalInstanceCanceled:
|
||||
return "已取消"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalStatusTagType 给前端 uview-plus u-tag 用的类型映射
|
||||
func oaApprovalStatusTagType(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalInstanceRunning:
|
||||
return "warning"
|
||||
case models.ApprovalInstanceApproved:
|
||||
return "success"
|
||||
case models.ApprovalInstanceRejected:
|
||||
return "error"
|
||||
case models.ApprovalInstanceWithdrawn, models.ApprovalInstanceCanceled:
|
||||
return "info"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalInstanceView 把 ApprovalInstance 组装为移动端列表/详情通用的视图结构
|
||||
func oaApprovalInstanceView(inst models.ApprovalInstance) map[string]interface{} {
|
||||
submitTime := ""
|
||||
if inst.SubmitTime != nil {
|
||||
submitTime = inst.SubmitTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
finishTime := ""
|
||||
if inst.FinishTime != nil {
|
||||
finishTime = inst.FinishTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
createTime := ""
|
||||
if !inst.CreateTime.IsZero() {
|
||||
createTime = inst.CreateTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": inst.ID,
|
||||
"biz_type": inst.BizType,
|
||||
"flow_id": inst.FlowID,
|
||||
"biz_id": inst.BizID,
|
||||
"biz_title": inst.BizTitle,
|
||||
"submit_user_id": inst.SubmitUserID,
|
||||
"submit_user_name": inst.SubmitUserName,
|
||||
"submit_dept_id": inst.SubmitDeptID,
|
||||
"current_node": inst.CurrentNode,
|
||||
"status": inst.Status,
|
||||
"status_label": oaApprovalStatusText(inst.Status),
|
||||
"status_tag": oaApprovalStatusTagType(inst.Status),
|
||||
"submit_time": submitTime,
|
||||
"finish_time": finishTime,
|
||||
"create_time": createTime,
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalBizTypes 返回本租户已配置(启用 + 未删除)的 biz_type 列表
|
||||
func oaApprovalBizTypes(tid uint64) []map[string]interface{} {
|
||||
var flows []models.TenantApprovalFlow
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1).
|
||||
OrderBy("module_code", "id").All(&flows)
|
||||
|
||||
seen := map[string]bool{}
|
||||
list := make([]map[string]interface{}, 0, len(flows))
|
||||
for _, f := range flows {
|
||||
biz := strings.TrimSpace(f.BizType)
|
||||
if biz == "" {
|
||||
biz = strings.TrimSpace(f.ModuleCode)
|
||||
}
|
||||
if biz == "" || seen[biz] {
|
||||
continue
|
||||
}
|
||||
seen[biz] = true
|
||||
list = append(list, map[string]interface{}{
|
||||
"value": biz,
|
||||
"label": biz,
|
||||
"module_code": f.ModuleCode,
|
||||
})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool {
|
||||
return list[i]["value"].(string) < list[j]["value"].(string)
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// ============================ 任务时间线 ============================
|
||||
|
||||
// oaApprovalTasksByInstance 取出某实例下的全部任务(按 sort / id 升序),
|
||||
// 同时按 (instance_id, node_key) 找当前节点的当前审批人。
|
||||
func oaApprovalTasksByInstance(instanceID uint64) []models.ApprovalTask {
|
||||
var tasks []models.ApprovalTask
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", instanceID).Filter("is_deleted", 0).
|
||||
OrderBy("sort", "id").All(&tasks)
|
||||
return tasks
|
||||
}
|
||||
|
||||
// oaApprovalTaskText / oaApprovalTaskTag 任务级状态文案/Tag 类型
|
||||
func oaApprovalTaskText(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalTaskPending:
|
||||
return "待审批"
|
||||
case models.ApprovalTaskApproved:
|
||||
return "已通过"
|
||||
case models.ApprovalTaskRejected:
|
||||
return "已驳回"
|
||||
case models.ApprovalTaskReturned:
|
||||
return "已退回"
|
||||
case models.ApprovalTaskSkipped:
|
||||
return "已跳过"
|
||||
case models.ApprovalTaskTimeout:
|
||||
return "已超时"
|
||||
case models.ApprovalTaskNotified:
|
||||
return "已通知"
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
func oaApprovalTaskTag(status int8) string {
|
||||
switch status {
|
||||
case models.ApprovalTaskPending:
|
||||
return "warning"
|
||||
case models.ApprovalTaskApproved:
|
||||
return "success"
|
||||
case models.ApprovalTaskRejected:
|
||||
return "error"
|
||||
case models.ApprovalTaskReturned, models.ApprovalTaskSkipped, models.ApprovalTaskTimeout:
|
||||
return "info"
|
||||
default:
|
||||
return "info"
|
||||
}
|
||||
}
|
||||
|
||||
// oaApprovalTaskView 任务视图(移动端时间线用)
|
||||
func oaApprovalTaskView(t models.ApprovalTask, isCurrent bool) map[string]interface{} {
|
||||
finishTime := ""
|
||||
if t.FinishTime != nil {
|
||||
finishTime = t.FinishTime.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
deadline := ""
|
||||
if t.Deadline != nil {
|
||||
deadline = t.Deadline.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": t.ID,
|
||||
"node_key": t.NodeKey,
|
||||
"node_name": t.NodeName,
|
||||
"approver_id": t.ApproverID,
|
||||
"approver_name": t.ApproverName,
|
||||
"multi_mode": t.MultiMode,
|
||||
"status": t.Status,
|
||||
"status_label": oaApprovalTaskText(t.Status),
|
||||
"status_tag": oaApprovalTaskTag(t.Status),
|
||||
"action": t.Action,
|
||||
"comment": t.Comment,
|
||||
"is_current": isCurrent,
|
||||
"deadline": deadline,
|
||||
"finish_time": finishTime,
|
||||
"create_time": t.CreateTime.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================ 路由方法 ============================
|
||||
|
||||
// ApprovalCategories GET /api/oa/approval/categories
|
||||
// 返回本租户可用的 biz_type 下拉项,移动端「发起审批」选择单据类型使用。
|
||||
func (c *AppOaController) ApprovalCategories() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.oaOk(map[string]interface{}{
|
||||
"list": oaApprovalBizTypes(uint64(claims.TenantId)),
|
||||
"total": len(oaApprovalBizTypes(uint64(claims.TenantId))),
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalFlows GET /api/oa/approval/flows
|
||||
// 按 biz_type 返回本租户可发起的流程(含流程名 + 节点名列表),
|
||||
// 用于「选择流程」下拉。biz_type 为空时返回全部。
|
||||
func (c *AppOaController) ApprovalFlows() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
biz := strings.TrimSpace(c.GetString("biz_type"))
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1)
|
||||
if biz != "" {
|
||||
qs = qs.Filter("biz_type", biz)
|
||||
}
|
||||
var flows []models.TenantApprovalFlow
|
||||
_, _ = qs.OrderBy("is_default", "id").All(&flows)
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(flows))
|
||||
for _, f := range flows {
|
||||
var nodes []models.TenantApprovalNode
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)).
|
||||
Filter("flow_id", f.ID).Filter("is_deleted", 0).
|
||||
OrderBy("sort", "id").All(&nodes)
|
||||
|
||||
nodeNames := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if n.NodeType == models.ApprovalNodeApproval {
|
||||
nodeNames = append(nodeNames, n.NodeName)
|
||||
}
|
||||
}
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": f.ID,
|
||||
"flow_code": f.FlowCode,
|
||||
"flow_name": f.FlowName,
|
||||
"biz_type": f.BizType,
|
||||
"module_code": f.ModuleCode,
|
||||
"is_default": f.IsDefault,
|
||||
"nodes": nodeNames,
|
||||
})
|
||||
}
|
||||
c.oaOk(map[string]interface{}{"list": list, "total": len(list)})
|
||||
}
|
||||
|
||||
// ApprovalList GET /api/oa/approval/list
|
||||
// 我发起的审批实例(移动端「我的发起」列表)。
|
||||
// 支持 keyword / status / biz_type 过滤。
|
||||
func (c *AppOaController) ApprovalList() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||
statusStr := strings.TrimSpace(c.GetString("status"))
|
||||
bizType := strings.TrimSpace(c.GetString("biz_type"))
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("tid", tid).
|
||||
Filter("submit_user_id", uid).
|
||||
Filter("is_deleted", 0)
|
||||
if bizType != "" {
|
||||
qs = qs.Filter("biz_type", bizType)
|
||||
}
|
||||
if statusStr != "" {
|
||||
if s, err := strconv.Atoi(statusStr); err == nil {
|
||||
qs = qs.Filter("status", int8(s))
|
||||
}
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
var rows []models.ApprovalInstance
|
||||
_, err := qs.OrderBy("-id").
|
||||
Limit(pageSize).Offset((page - 1) * pageSize).All(&rows)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.oaErr(500, 500, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, inst := range rows {
|
||||
item := oaApprovalInstanceView(inst)
|
||||
if keyword != "" {
|
||||
if !strings.Contains(strings.ToLower(inst.BizTitle), strings.ToLower(keyword)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalTodo GET /api/oa/approval/todo
|
||||
// 我的待办(作为审批人)。返回任务列表 + 关联实例基本信息,
|
||||
// 移动端「待我审批」直接展示。
|
||||
func (c *AppOaController) ApprovalTodo() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 20)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 20
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("tid", tid).
|
||||
Filter("approver_id", uid).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Filter("is_deleted", 0)
|
||||
total, _ := qs.Count()
|
||||
|
||||
var tasks []models.ApprovalTask
|
||||
_, err := qs.OrderBy("-id").
|
||||
Limit(pageSize).Offset((page - 1) * pageSize).All(&tasks)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
c.oaErr(500, 500, "查询失败")
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]map[string]interface{}, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
// 关联查询实例,校验实例仍在审批中(防止实例已结束但任务未及时同步)
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", t.InstanceID).Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
continue
|
||||
}
|
||||
if inst.Status != models.ApprovalInstanceRunning {
|
||||
continue
|
||||
}
|
||||
view := oaApprovalTaskView(t, true)
|
||||
view["instance"] = oaApprovalInstanceView(inst)
|
||||
list = append(list, view)
|
||||
}
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"list": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalDetail GET /api/oa/approval/detail/:id
|
||||
// 审批详情:实例基本信息 + 任务时间线 + 当前节点可执行动作。
|
||||
func (c *AppOaController) ApprovalDetail() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
id := oaApprovalIDParam(c)
|
||||
if id == 0 {
|
||||
c.oaErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
c.oaErr(404, 404, "审批不存在")
|
||||
return
|
||||
}
|
||||
|
||||
// 权限:仅发起人或当前节点的审批人可查看
|
||||
// 两者独立判断:同一个用户既可能是发起人也可能是审批人(如自审批场景)
|
||||
isSubmitter := inst.SubmitUserID == uid
|
||||
isApprover := false
|
||||
var currentTaskID uint64
|
||||
{
|
||||
var t models.ApprovalTask
|
||||
err := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", inst.ID).
|
||||
Filter("approver_id", uid).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Filter("is_deleted", 0).One(&t)
|
||||
if err == nil {
|
||||
isApprover = true
|
||||
currentTaskID = t.ID
|
||||
}
|
||||
}
|
||||
if !isSubmitter && !isApprover {
|
||||
c.oaErr(403, 403, "无权查看该审批")
|
||||
return
|
||||
}
|
||||
|
||||
tasks := oaApprovalTasksByInstance(inst.ID)
|
||||
timeline := make([]map[string]interface{}, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
timeline = append(timeline, oaApprovalTaskView(t, t.NodeKey == inst.CurrentNode))
|
||||
}
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"item": oaApprovalInstanceView(inst),
|
||||
"timeline": timeline,
|
||||
"can_approve": isApprover,
|
||||
"can_withdraw": isSubmitter && inst.Status == models.ApprovalInstanceRunning,
|
||||
"current_task_id": currentTaskID,
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalSubmit POST /api/oa/approval/submit
|
||||
// 提交审批(发起)。引擎内部完成:创建实例 → 找首个审批节点 → 生成任务。
|
||||
func (c *AppOaController) ApprovalSubmit() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var p oaApprovalSubmitPayload
|
||||
if !oaApprovalReadJSON(c, &p) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.BizType) == "" {
|
||||
c.oaErr(400, 400, "请选择单据类型")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(p.BizTitle) == "" {
|
||||
c.oaErr(400, 400, "请填写单据标题")
|
||||
return
|
||||
}
|
||||
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
// 发起人所在部门(用于审批引擎按部门主管匹配审批人)
|
||||
var deptID uint64
|
||||
var tu models.SystemTenantUser
|
||||
if models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).One(&tu) == nil {
|
||||
deptID = tu.OrgID
|
||||
}
|
||||
|
||||
in := approvalEngine.SubmitInput{
|
||||
Scope: models.ApprovalScopeTenant,
|
||||
Tid: tid,
|
||||
BizType: strings.TrimSpace(p.BizType),
|
||||
FlowCode: strings.TrimSpace(p.FlowCode),
|
||||
FlowID: p.FlowID,
|
||||
BizID: p.BizID,
|
||||
BizTitle: strings.TrimSpace(p.BizTitle),
|
||||
SubmitUID: uid,
|
||||
SubmitName: oaApprovalUserName(c, claims),
|
||||
DeptID: deptID,
|
||||
FormData: p.FormData,
|
||||
}
|
||||
|
||||
instID, err := approvalEngine.Submit(in)
|
||||
if err != nil {
|
||||
// ErrNoFlow / ErrNoApprover 等业务错误统一以 400 返回
|
||||
c.oaErr(400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
_ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", instID).One(&inst)
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"id": instID,
|
||||
"item": oaApprovalInstanceView(inst),
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalApprove POST /api/oa/approval/approve
|
||||
// 审批人通过/驳回一条任务。
|
||||
func (c *AppOaController) ApprovalApprove() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
var p oaApprovalApprovePayload
|
||||
if !oaApprovalReadJSON(c, &p) {
|
||||
return
|
||||
}
|
||||
if p.TaskID == 0 {
|
||||
// 发起人视角在「我的发起」详情里也会看到「通过」按钮(操作入口对齐 Web 端),
|
||||
// 但发起人不是当前审批人,没有可处理的 task:给一个明确的提示而不是含糊的「缺少任务ID」。
|
||||
c.oaErr(403, 403, "您不是当前审批人,无法通过/驳回该审批")
|
||||
return
|
||||
}
|
||||
|
||||
// 校验任务归属本租户、当前审批人
|
||||
var task models.ApprovalTask
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("id", p.TaskID).
|
||||
Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&task); err != nil {
|
||||
c.oaErr(404, 404, "任务不存在")
|
||||
return
|
||||
}
|
||||
if task.ApproverID != uid {
|
||||
c.oaErr(403, 403, "您无权处理该任务")
|
||||
return
|
||||
}
|
||||
if task.Status != models.ApprovalTaskPending {
|
||||
c.oaErr(400, 400, "该任务已处理")
|
||||
return
|
||||
}
|
||||
|
||||
res, err := approvalEngine.Approve(p.TaskID, approvalEngine.ApproveInput{
|
||||
ApproverID: uid,
|
||||
Pass: p.Pass,
|
||||
Comment: strings.TrimSpace(p.Comment),
|
||||
})
|
||||
if err != nil {
|
||||
c.oaErr(500, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
_ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", res.InstanceID).One(&inst)
|
||||
|
||||
c.oaOk(map[string]interface{}{
|
||||
"finished": res.Finished,
|
||||
"instance_stat": res.InstanceStat,
|
||||
"current_node": res.CurrentNode,
|
||||
"message": res.Message,
|
||||
"item": oaApprovalInstanceView(inst),
|
||||
})
|
||||
}
|
||||
|
||||
// ApprovalWithdraw POST /api/oa/approval/withdraw/:id
|
||||
// 发起人撤回(仅审批中允许)。
|
||||
func (c *AppOaController) ApprovalWithdraw() {
|
||||
claims, ok := oaApprovalClaims(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
uid := oaApprovalSubmitUID(claims)
|
||||
|
||||
id := oaApprovalIDParam(c)
|
||||
if id == 0 {
|
||||
c.oaErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Filter("tid", tid).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
c.oaErr(404, 404, "审批不存在")
|
||||
return
|
||||
}
|
||||
if inst.SubmitUserID != uid {
|
||||
c.oaErr(403, 403, "仅发起人可撤回")
|
||||
return
|
||||
}
|
||||
if inst.Status != models.ApprovalInstanceRunning {
|
||||
c.oaErr(400, 400, "当前状态不允许撤回")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).Update(orm.Params{
|
||||
"status": models.ApprovalInstanceWithdrawn,
|
||||
"finish_time": now,
|
||||
"update_time": now,
|
||||
})
|
||||
// 同实例剩余待办置为已跳过
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", id).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Update(orm.Params{"status": models.ApprovalTaskSkipped, "update_time": now})
|
||||
|
||||
// 回写业务单据状态(如报销单置为已撤回),保持业务列表与审批中心一致
|
||||
approvalEngine.SyncBizResult(id, models.ApprovalInstanceWithdrawn)
|
||||
|
||||
var updated models.ApprovalInstance
|
||||
_ = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).One(&updated)
|
||||
c.oaOk(map[string]interface{}{"item": oaApprovalInstanceView(updated)})
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
approvalEngine "server/services/approval"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
@@ -377,6 +378,8 @@ func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword strin
|
||||
}
|
||||
list := make([]map[string]interface{}, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
// 审批已结束但业务状态未及时回写时,读取即自愈
|
||||
row.Status = syncReimburseStatusFromInstance(&row)
|
||||
list = append(list, map[string]interface{}{
|
||||
"id": row.ID,
|
||||
"tid": row.Tid,
|
||||
@@ -430,9 +433,58 @@ func reimburseDetailData(claims *jwtutil.Claims, id uint64, scheme, host string)
|
||||
if err != nil && err != orm.ErrNoRows {
|
||||
return nil, 500, "查询明细失败: " + err.Error()
|
||||
}
|
||||
// 审批已结束但业务状态未及时回写时,读取即自愈
|
||||
row.Status = syncReimburseStatusFromInstance(row)
|
||||
return reimbursementDTO(*row, items, scheme, host), 200, ""
|
||||
}
|
||||
|
||||
// syncReimburseStatusFromInstance 报销单状态自愈:单据处于「审批中」时,
|
||||
// 按审批引擎中对应实例(biz_type=reimburse)的最终态回写业务状态。
|
||||
// 用于兜底引擎回写异常/历史数据,列表与详情读取时调用,无变化时原样返回。
|
||||
func syncReimburseStatusFromInstance(row *models.BackendReimbursement) int8 {
|
||||
if row == nil {
|
||||
return 0
|
||||
}
|
||||
if row.Status != 1 {
|
||||
return row.Status
|
||||
}
|
||||
inst, ok := findReimburseInstance(row.Tid, row.ID)
|
||||
if !ok || inst.Status == models.ApprovalInstanceRunning {
|
||||
return row.Status
|
||||
}
|
||||
approvalEngine.SyncBizResult(inst.ID, inst.Status)
|
||||
switch inst.Status {
|
||||
case models.ApprovalInstanceApproved:
|
||||
return 2
|
||||
case models.ApprovalInstanceRejected:
|
||||
return 3
|
||||
case models.ApprovalInstanceWithdrawn:
|
||||
return 4
|
||||
}
|
||||
return row.Status
|
||||
}
|
||||
|
||||
// findReimburseInstance 查报销单对应的审批实例。
|
||||
// 兼容两种 biz_type 写法(reimburse / 早期的 oa),并按 tid、biz_tid 依次匹配,
|
||||
// 这样租户侧与平台侧产生的实例都能找到。
|
||||
func findReimburseInstance(tid, bizID uint64) (*models.ApprovalInstance, bool) {
|
||||
if tid == 0 || bizID == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, key := range []string{"tid", "biz_tid"} {
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter(key, tid).
|
||||
Filter("biz_type__in", models.ApprovalBizReimburse, approvalEngine.BizTypeLegacyOA).
|
||||
Filter("biz_id", bizID).
|
||||
Filter("is_deleted", 0).
|
||||
OrderBy("-id").One(&inst); err == nil {
|
||||
return &inst, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Create POST /backend/reimbursements
|
||||
func (c *BackendReimburseController) Create() {
|
||||
claims, ok := c.claims()
|
||||
@@ -827,8 +879,12 @@ func (c *BackendReimburseController) Submit() {
|
||||
c.reply(200, "success", nil)
|
||||
}
|
||||
|
||||
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流,
|
||||
// 提交后状态置为审批中(1),并写入 action=1 的审批记录。
|
||||
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流。
|
||||
//
|
||||
// 提交进入统一审批引擎(biz_type=reimburse、biz_id=报销单ID),引擎负责节点流转;
|
||||
// 审批最终结果由 services/approval.SyncBizResult 回写到报销单状态,保证
|
||||
// 「审批中心」与「报销管理」看到的状态一致。
|
||||
// 提交后报销单置为审批中(1),并写入 action=1 的审批记录供业务详情时间线展示。
|
||||
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
||||
row, err := findOwnedReimbursement(claims, id)
|
||||
if err != nil {
|
||||
@@ -837,24 +893,57 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
||||
if row.Status != 0 {
|
||||
return 400, "当前状态不可提交"
|
||||
}
|
||||
var flow models.BackendApprovalFlow
|
||||
err = models.Orm.QueryTable(new(models.BackendApprovalFlow)).
|
||||
Filter("tid", claims.TenantId).Filter("is_active", 1).Filter("is_deleted", 0).
|
||||
OrderBy("step_order").One(&flow)
|
||||
if err != nil {
|
||||
tid := uint64(claims.TenantId)
|
||||
flowID := findReimburseFlowID(tid)
|
||||
if flowID == 0 {
|
||||
return 400, "尚未配置审批流程"
|
||||
}
|
||||
|
||||
instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{
|
||||
Scope: models.ApprovalScopeTenant,
|
||||
Tid: tid,
|
||||
FlowID: flowID,
|
||||
BizType: models.ApprovalBizReimburse,
|
||||
BizID: id,
|
||||
BizTitle: reimburseBizTitle(row),
|
||||
SubmitUID: uint64(claims.UserID),
|
||||
SubmitName: claims.Username,
|
||||
DeptID: reimburseSubmitDeptID(tid, uint64(claims.UserID)),
|
||||
FormData: map[string]interface{}{
|
||||
"id": id,
|
||||
"total_amount": row.TotalAmount,
|
||||
"apply_date": row.ApplyDate.Format("2006-01-02"),
|
||||
"description": reimburseDescription(row),
|
||||
"department_id": row.DepartmentID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return 400, err.Error()
|
||||
}
|
||||
|
||||
// 流程未配置审批节点时引擎会直接通过,此时报销单同步为已通过(2)
|
||||
status := int8(1)
|
||||
var inst models.ApprovalInstance
|
||||
if e := models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id", instID).One(&inst); e == nil {
|
||||
switch inst.Status {
|
||||
case models.ApprovalInstanceApproved:
|
||||
status = 2
|
||||
case models.ApprovalInstanceRejected:
|
||||
status = 3
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
|
||||
"status": 1, "current_step": flow.StepOrder, "current_approver_id": 0, "update_time": now,
|
||||
"status": status, "current_step": 0, "current_approver_id": 0, "update_time": now,
|
||||
})
|
||||
if err != nil {
|
||||
return 500, "提交失败"
|
||||
}
|
||||
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
|
||||
Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
|
||||
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: 1,
|
||||
Step: flow.StepOrder, PrevStep: 0,
|
||||
Tid: tid, UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
|
||||
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: models.ApprovalActionSubmit,
|
||||
Step: 0, PrevStep: 0,
|
||||
})
|
||||
if err != nil {
|
||||
return 500, "提交审批记录失败"
|
||||
@@ -862,6 +951,52 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
||||
return 200, ""
|
||||
}
|
||||
|
||||
// findReimburseFlowID 查找报销单可用的启用审批流:
|
||||
// 优先精确匹配 biz_type=reimburse;若租户未区分单据类型(只按模块 oa 配置),回退到模块流程。
|
||||
func findReimburseFlowID(tid uint64) uint64 {
|
||||
var flow models.TenantApprovalFlow
|
||||
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("biz_type", models.ApprovalBizReimburse).
|
||||
Filter("status", 1).Filter("is_deleted", 0).
|
||||
OrderBy("-is_default", "-id").One(&flow); err == nil {
|
||||
return flow.ID
|
||||
}
|
||||
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("module_code", "oa").
|
||||
Filter("status", 1).Filter("is_deleted", 0).
|
||||
Filter("biz_type__in", "", "oa").
|
||||
OrderBy("-is_default", "-id").One(&flow); err == nil {
|
||||
return flow.ID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// reimburseSubmitDeptID 取发起人所属组织ID,供审批引擎按「部门主管」规则解析审批人。
|
||||
func reimburseSubmitDeptID(tid, uid uint64) uint64 {
|
||||
var tu models.SystemTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true).One(&tu); err != nil {
|
||||
return 0
|
||||
}
|
||||
return tu.OrgID
|
||||
}
|
||||
|
||||
// reimburseBizTitle 审批标题:优先报销说明,其次带单据号。
|
||||
func reimburseBizTitle(row *models.BackendReimbursement) string {
|
||||
if desc := reimburseDescription(row); desc != "" {
|
||||
return desc
|
||||
}
|
||||
return fmt.Sprintf("报销单 #%d", row.ID)
|
||||
}
|
||||
|
||||
func reimburseDescription(row *models.BackendReimbursement) string {
|
||||
if row == nil || row.Description == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*row.Description)
|
||||
}
|
||||
|
||||
type reimbursementStatusPayload struct {
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user