更新
This commit is contained in:
1 parent
184d5b0403
commit
a758966fae
18 files changed
+2002
-19
No files matched your search
@@ -307,6 +307,97 @@ func (c *AppOaController) ExpenseRecords() {
|
||||
c.oaOk(rows)
|
||||
}
|
||||
|
||||
// expenseActPayload 审批操作请求体。
|
||||
type expenseActPayload struct {
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// expenseReadActPayload 读取审批意见(可空)。
|
||||
func (c *AppOaController) expenseReadActPayload() string {
|
||||
var payload expenseActPayload
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err == nil && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
}
|
||||
return strings.TrimSpace(payload.Comment)
|
||||
}
|
||||
|
||||
// ExpenseApprove POST /api/oa/expense/approve/:id 通过当前审批节点(body: {comment})。
|
||||
// 审批人视角:无需是单据创建人,只要是当前节点待办审批人即可操作。
|
||||
func (c *AppOaController) ExpenseApprove() {
|
||||
claims, err := c.oaClaims()
|
||||
if err != nil {
|
||||
c.oaErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.oaScheduleIDParam()
|
||||
if id == 0 {
|
||||
c.oaErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
code, msg, hint := reimburseActCore(claims, id, true, c.expenseReadActPayload())
|
||||
if msg != "" {
|
||||
c.oaErr(code, code, msg)
|
||||
return
|
||||
}
|
||||
c.oaOk(map[string]interface{}{"message": hint})
|
||||
}
|
||||
|
||||
// ExpenseReject POST /api/oa/expense/reject/:id 驳回报销单(body: {comment})。
|
||||
func (c *AppOaController) ExpenseReject() {
|
||||
claims, err := c.oaClaims()
|
||||
if err != nil {
|
||||
c.oaErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.oaScheduleIDParam()
|
||||
if id == 0 {
|
||||
c.oaErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
code, msg, hint := reimburseActCore(claims, id, false, c.expenseReadActPayload())
|
||||
if msg != "" {
|
||||
c.oaErr(code, code, msg)
|
||||
return
|
||||
}
|
||||
c.oaOk(map[string]interface{}{"message": hint})
|
||||
}
|
||||
|
||||
// ExpenseMyApproveTask GET /api/oa/expense/my-approve-task/:id
|
||||
// 查当前用户在该单据上的待审批任务:返回 {task_id,node_name},无待办返回 data=null。
|
||||
// 小程序详情页据此决定是否展示「通过/驳回」按钮。
|
||||
func (c *AppOaController) ExpenseMyApproveTask() {
|
||||
claims, err := c.oaClaims()
|
||||
if err != nil {
|
||||
c.oaErr(401, 401, "未登录或无权限")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.oaScheduleIDParam()
|
||||
if id == 0 {
|
||||
c.oaErr(400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
|
||||
task, err := reimburseMyTaskCore(claims, id)
|
||||
if err != nil {
|
||||
c.oaErr(500, 500, "查询审批任务失败")
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
c.oaOk(nil)
|
||||
return
|
||||
}
|
||||
c.oaOk(map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"node_name": task.NodeName,
|
||||
})
|
||||
}
|
||||
|
||||
// ExpenseTypes GET /api/oa/expense/types 费用类型下拉(全局通用 + 本租户专有,仅启用)。
|
||||
func (c *AppOaController) ExpenseTypes() {
|
||||
claims, err := c.oaClaims()
|
||||
|
||||
@@ -380,6 +380,73 @@ func (c *BackendApprovalController) FlowDelete() {
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// FlowPerm POST /backend/approval/flow/perm
|
||||
// 审批中心「审批权限」设置:只更新流程权限相关字段(适用部门 / 是否默认 / 启用状态),
|
||||
// 不动节点编排。设为默认流时自动取消同分类(biz_type)下其它流程的默认标记。
|
||||
func (c *BackendApprovalController) FlowPerm() {
|
||||
claims, err := requireBackend(&c.Controller)
|
||||
if err != nil {
|
||||
jsonErr(&c.Controller, 401, 401, err.Error())
|
||||
return
|
||||
}
|
||||
tid := uint64(claims.TenantId)
|
||||
|
||||
var payload struct {
|
||||
ID uint64 `json:"id"`
|
||||
ApplyDeptIDs []uint64 `json:"apply_dept_ids"`
|
||||
IsDefault int8 `json:"is_default"`
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
if err := c.readTenantBody(&payload); err != nil {
|
||||
jsonErr(&c.Controller, 400, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if payload.ID == 0 {
|
||||
jsonErr(&c.Controller, 400, 400, "无效ID")
|
||||
return
|
||||
}
|
||||
var f models.TenantApprovalFlow
|
||||
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("id", payload.ID).Filter("tid", tid).Filter("is_deleted", 0).One(&f); err != nil {
|
||||
jsonErr(&c.Controller, 404, 404, "流程不存在")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
params := orm.Params{
|
||||
"apply_dept_ids": joinUintCSV(payload.ApplyDeptIDs),
|
||||
"is_default": payload.IsDefault,
|
||||
"status": payload.Status,
|
||||
"update_time": now,
|
||||
}
|
||||
if _, err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("id", payload.ID).Filter("tid", tid).Update(params); err != nil {
|
||||
jsonErr(&c.Controller, 500, 500, "保存失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 设为默认流:取消同分类其它流程的默认标记,保证每个分类只有一条默认流
|
||||
if payload.IsDefault == 1 {
|
||||
models.Orm.QueryTable(new(models.TenantApprovalFlow)).
|
||||
Filter("tid", tid).Filter("biz_type", f.BizType).
|
||||
Filter("id__ne", payload.ID).Filter("is_deleted", 0).
|
||||
Update(orm.Params{"is_default": 0, "update_time": now})
|
||||
}
|
||||
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功"}
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// joinUintCSV 把 ID 列表拼成逗号分隔串(与 parseIDSlice 互逆)。
|
||||
func joinUintCSV(ids []uint64) string {
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id > 0 {
|
||||
parts = append(parts, strconv.FormatUint(id, 10))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// Options GET /backend/approval/options
|
||||
// 编排界面数据源:当前租户的功能模块 + 审批人候选(用户 / 角色 / 部门 / 职位)。
|
||||
func (c *BackendApprovalController) Options() {
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
approvalEngine "server/services/approval"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
)
|
||||
|
||||
// ApprovalCenterController 审批中心(OA 审批工作台):
|
||||
// - 待我审批 / 我已审批 / 抄送我的:按当前用户查询运行期任务(yz_approval_task);
|
||||
// - 我发起的:按提交人查询审批实例(yz_approval_instance);
|
||||
// - 通用审批操作:复用运行期引擎 services/approval 的 Approve(或签/会签/多级推进均在引擎内),
|
||||
// 业务侧回调(如报销单状态同步、审批记录时间线)按 biz_type 分发。
|
||||
//
|
||||
// 目前走引擎的 OA 业务为报销(biz_type=reimburse,回退 oa),后续新业务接入后无需改本控制器。
|
||||
type ApprovalCenterController struct {
|
||||
beego.Controller
|
||||
}
|
||||
|
||||
// centerTaskType 任务列表视角:pending-待我审批 done-我已审批 cc-抄送我的
|
||||
const centerTaskTypeAll = "pending|done|cc"
|
||||
|
||||
func (c *ApprovalCenterController) centerJSON(httpStatus, bizCode int, msg string, data interface{}) {
|
||||
c.Ctx.Output.SetStatus(httpStatus)
|
||||
resp := map[string]interface{}{"code": bizCode, "msg": msg}
|
||||
if data != nil {
|
||||
resp["data"] = data
|
||||
}
|
||||
c.Data["json"] = resp
|
||||
_ = c.ServeJSON()
|
||||
}
|
||||
|
||||
// centerClaims 解析 backend 令牌。
|
||||
func (c *ApprovalCenterController) centerClaims() (*jwtutil.Claims, error) {
|
||||
return requireBackend(&c.Controller)
|
||||
}
|
||||
|
||||
// centerTaskView 任务行(含实例概要信息),列表与操作返回共用。
|
||||
type centerTaskView struct {
|
||||
TaskID uint64 `json:"task_id"`
|
||||
NodeName string `json:"node_name"`
|
||||
NodeSort int `json:"node_sort"`
|
||||
MultiMode int8 `json:"multi_mode"`
|
||||
Status int8 `json:"status"`
|
||||
Comment string `json:"comment"`
|
||||
FinishTime string `json:"finish_time"`
|
||||
Deadline string `json:"deadline"`
|
||||
InstanceID uint64 `json:"instance_id"`
|
||||
InstanceStatus int8 `json:"instance_status"`
|
||||
BizType string `json:"biz_type"`
|
||||
BizID uint64 `json:"biz_id"`
|
||||
BizTitle string `json:"biz_title"`
|
||||
FlowName string `json:"flow_name"`
|
||||
SubmitUserID uint64 `json:"submit_user_id"`
|
||||
SubmitUserName string `json:"submit_user_name"`
|
||||
SubmitTime string `json:"submit_time"`
|
||||
CurrentNode string `json:"current_node"`
|
||||
}
|
||||
|
||||
// TaskList GET /backend/approval/center/task/list?type=pending|done|cc&page=&pageSize=
|
||||
// 我的任务列表。pending=待我审批(status=待办);done=我已审批(通过/驳回);
|
||||
// cc=抄送我的(通知节点生成的已通知任务)。
|
||||
func (c *ApprovalCenterController) TaskList() {
|
||||
claims, err := c.centerClaims()
|
||||
if err != nil {
|
||||
c.centerJSON(401, 401, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
taskType := c.GetString("type")
|
||||
switch taskType {
|
||||
case "pending", "done", "cc":
|
||||
default:
|
||||
c.centerJSON(400, 400, "type 参数须为 "+centerTaskTypeAll, nil)
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 10)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("approver_id", claims.UserID).
|
||||
Filter("is_deleted", 0)
|
||||
switch taskType {
|
||||
case "pending":
|
||||
qs = qs.Filter("status", models.ApprovalTaskPending)
|
||||
case "done":
|
||||
qs = qs.Filter("status__in", []int8{models.ApprovalTaskApproved, models.ApprovalTaskRejected})
|
||||
case "cc":
|
||||
qs = qs.Filter("status", models.ApprovalTaskNotified)
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
var tasks []models.ApprovalTask
|
||||
if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&tasks); err != nil {
|
||||
c.centerJSON(500, 500, "查询任务失败", nil)
|
||||
return
|
||||
}
|
||||
|
||||
list := make([]centerTaskView, 0, len(tasks))
|
||||
if len(tasks) > 0 {
|
||||
instMap := c.loadInstanceMap(tasks)
|
||||
for _, t := range tasks {
|
||||
view := centerTaskView{
|
||||
TaskID: t.ID,
|
||||
NodeName: t.NodeName,
|
||||
NodeSort: t.Sort,
|
||||
MultiMode: t.MultiMode,
|
||||
Status: t.Status,
|
||||
Comment: t.Comment,
|
||||
}
|
||||
if t.FinishTime != nil {
|
||||
view.FinishTime = t.FinishTime.Format("2006-01-02 15:04")
|
||||
}
|
||||
if t.Deadline != nil {
|
||||
view.Deadline = t.Deadline.Format("2006-01-02 15:04")
|
||||
}
|
||||
if inst, ok := instMap[t.InstanceID]; ok {
|
||||
view.InstanceID = inst.ID
|
||||
view.InstanceStatus = inst.Status
|
||||
view.BizType = inst.BizType
|
||||
view.BizID = inst.BizID
|
||||
view.BizTitle = inst.BizTitle
|
||||
view.SubmitUserID = inst.SubmitUserID
|
||||
view.SubmitUserName = inst.SubmitUserName
|
||||
if inst.SubmitTime != nil {
|
||||
view.SubmitTime = inst.SubmitTime.Format("2006-01-02 15:04")
|
||||
}
|
||||
view.CurrentNode = inst.CurrentNode
|
||||
}
|
||||
// 兜底:任务行自带的业务信息(实例被删时仍可展示)
|
||||
if view.BizTitle == "" {
|
||||
view.BizType = t.BizType
|
||||
view.BizID = t.BizID
|
||||
}
|
||||
list = append(list, view)
|
||||
}
|
||||
}
|
||||
c.centerJSON(200, 200, "ok", map[string]interface{}{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// loadInstanceMap 批量加载任务关联的实例。
|
||||
func (c *ApprovalCenterController) loadInstanceMap(tasks []models.ApprovalTask) map[uint64]models.ApprovalInstance {
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
seen := make(map[uint64]bool)
|
||||
for _, t := range tasks {
|
||||
if t.InstanceID > 0 && !seen[t.InstanceID] {
|
||||
seen[t.InstanceID] = true
|
||||
ids = append(ids, t.InstanceID)
|
||||
}
|
||||
}
|
||||
instMap := make(map[uint64]models.ApprovalInstance, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return instMap
|
||||
}
|
||||
var insts []models.ApprovalInstance
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id__in", ids).All(&insts)
|
||||
for _, inst := range insts {
|
||||
instMap[inst.ID] = inst
|
||||
}
|
||||
return instMap
|
||||
}
|
||||
|
||||
// TaskAct POST /backend/approval/center/task/act
|
||||
// 通用审批操作(body: {task_id, pass, comment})。业务回调按 biz_type 分发:
|
||||
// 报销类(reimburse/oa)同步单据状态并写审批记录;其它业务仅推进引擎。
|
||||
func (c *ApprovalCenterController) TaskAct() {
|
||||
claims, err := c.centerClaims()
|
||||
if err != nil {
|
||||
c.centerJSON(401, 401, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
TaskID uint64 `json:"task_id"`
|
||||
Pass bool `json:"pass"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err != nil || json.Unmarshal(raw, &payload) != nil || payload.TaskID == 0 {
|
||||
c.centerJSON(400, 400, "参数错误", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var task models.ApprovalTask
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("id", payload.TaskID).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("approver_id", claims.UserID).
|
||||
Filter("is_deleted", 0).One(&task); err != nil {
|
||||
c.centerJSON(404, 404, "审批任务不存在", nil)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := approvalEngine.Approve(task.ID, approvalEngine.ApproveInput{
|
||||
ApproverID: uint64(claims.UserID),
|
||||
Pass: payload.Pass,
|
||||
Comment: payload.Comment,
|
||||
})
|
||||
if err != nil {
|
||||
c.centerJSON(400, 400, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// OA 报销业务回调:同步单据状态 + 时间线记录
|
||||
if isReimburseBizType(task.BizType) {
|
||||
applyReimburseApprovalResult(uint64(claims.TenantId), task.BizID,
|
||||
uint64(claims.UserID), claims.Username, task.Sort,
|
||||
payload.Pass, payload.Comment, result.Finished, result.InstanceStat)
|
||||
}
|
||||
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "操作成功"
|
||||
}
|
||||
c.centerJSON(200, 200, msg, map[string]interface{}{
|
||||
"finished": result.Finished,
|
||||
})
|
||||
}
|
||||
|
||||
// InstanceList GET /backend/approval/center/instance/list?status=&page=&pageSize=
|
||||
// 我发起的审批实例列表(status 可选:1审批中 2通过 3驳回 4撤回)。
|
||||
func (c *ApprovalCenterController) InstanceList() {
|
||||
claims, err := c.centerClaims()
|
||||
if err != nil {
|
||||
c.centerJSON(401, 401, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
page, _ := c.GetInt("page", 1)
|
||||
pageSize, _ := c.GetInt("pageSize", 10)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("submit_user_id", claims.UserID).
|
||||
Filter("is_deleted", 0)
|
||||
if status, err := c.GetInt8("status"); err == nil && status > 0 {
|
||||
qs = qs.Filter("status", status)
|
||||
}
|
||||
|
||||
total, _ := qs.Count()
|
||||
var insts []models.ApprovalInstance
|
||||
if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&insts); err != nil {
|
||||
c.centerJSON(500, 500, "查询失败", nil)
|
||||
return
|
||||
}
|
||||
|
||||
flowNames := c.loadFlowNames(insts)
|
||||
list := make([]map[string]interface{}, 0, len(insts))
|
||||
for _, inst := range insts {
|
||||
row := map[string]interface{}{
|
||||
"id": inst.ID,
|
||||
"biz_type": inst.BizType,
|
||||
"biz_id": inst.BizID,
|
||||
"biz_title": inst.BizTitle,
|
||||
"status": inst.Status,
|
||||
"current_node": inst.CurrentNode,
|
||||
"submit_time": formatCenterTime(inst.SubmitTime),
|
||||
"finish_time": formatCenterTime(inst.FinishTime),
|
||||
"submit_dept_name": "",
|
||||
}
|
||||
if name, ok := flowNames[inst.FlowID]; ok {
|
||||
row["flow_name"] = name
|
||||
}
|
||||
list = append(list, row)
|
||||
}
|
||||
c.centerJSON(200, 200, "ok", map[string]interface{}{"list": list, "total": total})
|
||||
}
|
||||
|
||||
// loadFlowNames 批量加载实例关联的流程名称。
|
||||
func (c *ApprovalCenterController) loadFlowNames(insts []models.ApprovalInstance) map[uint64]string {
|
||||
ids := make([]uint64, 0, len(insts))
|
||||
seen := make(map[uint64]bool)
|
||||
for _, inst := range insts {
|
||||
if inst.FlowID > 0 && !seen[inst.FlowID] {
|
||||
seen[inst.FlowID] = true
|
||||
ids = append(ids, inst.FlowID)
|
||||
}
|
||||
}
|
||||
names := make(map[uint64]string, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return names
|
||||
}
|
||||
var flows []models.TenantApprovalFlow
|
||||
_, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).Filter("id__in", ids).All(&flows)
|
||||
for _, f := range flows {
|
||||
names[f.ID] = f.FlowName
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// InstanceDetail GET /backend/approval/center/instance/detail/:id
|
||||
// 实例详情:实例信息 + 节点任务时间线(含通知/抄送任务),按 sort 升序。
|
||||
func (c *ApprovalCenterController) InstanceDetail() {
|
||||
claims, err := c.centerClaims()
|
||||
if err != nil {
|
||||
c.centerJSON(401, 401, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||
if id == 0 {
|
||||
c.centerJSON(400, 400, "无效ID", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", id).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("is_deleted", 0).One(&inst); err != nil {
|
||||
c.centerJSON(404, 404, "审批实例不存在", nil)
|
||||
return
|
||||
}
|
||||
|
||||
var tasks []models.ApprovalTask
|
||||
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", inst.ID).
|
||||
Filter("is_deleted", 0).
|
||||
OrderBy("sort", "id").All(&tasks)
|
||||
|
||||
timeline := make([]map[string]interface{}, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
timeline = append(timeline, map[string]interface{}{
|
||||
"task_id": t.ID,
|
||||
"node_key": t.NodeKey,
|
||||
"node_name": t.NodeName,
|
||||
"node_sort": t.Sort,
|
||||
"approver_id": t.ApproverID,
|
||||
"approver": t.ApproverName,
|
||||
"status": t.Status,
|
||||
"comment": t.Comment,
|
||||
"finish_time": formatCenterTime(t.FinishTime),
|
||||
})
|
||||
}
|
||||
|
||||
flowName := ""
|
||||
if name, ok := c.loadFlowNames([]models.ApprovalInstance{inst})[inst.FlowID]; ok {
|
||||
flowName = name
|
||||
}
|
||||
|
||||
c.centerJSON(200, 200, "ok", map[string]interface{}{
|
||||
"id": inst.ID,
|
||||
"biz_type": inst.BizType,
|
||||
"biz_id": inst.BizID,
|
||||
"biz_title": inst.BizTitle,
|
||||
"status": inst.Status,
|
||||
"flow_name": flowName,
|
||||
"submit_user_name": inst.SubmitUserName,
|
||||
"submit_time": formatCenterTime(inst.SubmitTime),
|
||||
"finish_time": formatCenterTime(inst.FinishTime),
|
||||
"current_node": inst.CurrentNode,
|
||||
"timeline": timeline,
|
||||
})
|
||||
}
|
||||
|
||||
// formatCenterTime 时间指针格式化(空返回空串)。
|
||||
func formatCenterTime(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02 15:04")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package controllers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/services"
|
||||
"server/services/approval"
|
||||
|
||||
"github.com/beego/beego/v2/client/orm"
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
@@ -355,8 +357,39 @@ func reimburseListData(claims *jwtutil.Claims, page, pageSize int, keyword strin
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.BackendReimbursement)).
|
||||
Filter("tid", claims.TenantId).Filter("user_id", claims.UserID).Filter("is_deleted", 0)
|
||||
|
||||
// 待我审批(status=-1 约定值):审批人视角,切换为「我名下运行中实例的待办单据」
|
||||
// 查询(yz_approval_task.approver_id=当前用户),不再按创建人 user_id 过滤
|
||||
mineOnly := hasStatus && status == -1
|
||||
if mineOnly {
|
||||
hasStatus = false
|
||||
taskQs := models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("scope", models.ApprovalScopeTenant).
|
||||
Filter("tid", claims.TenantId).
|
||||
Filter("approver_id", claims.UserID).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Filter("is_deleted", 0).
|
||||
Filter("biz_type__in", reimburseFallbackBizTypes)
|
||||
var tasks []models.ApprovalTask
|
||||
if _, err := taskQs.All(&tasks, "biz_id"); err != nil {
|
||||
return nil, 500, "查询审批待办失败"
|
||||
}
|
||||
ids := make([]uint64, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if t.BizID > 0 {
|
||||
ids = append(ids, t.BizID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return map[string]interface{}{"list": []map[string]interface{}{}, "total": 0}, 200, ""
|
||||
}
|
||||
qs = models.Orm.QueryTable(new(models.BackendReimbursement)).
|
||||
Filter("tid", claims.TenantId).Filter("id__in", ids).Filter("is_deleted", 0)
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
qs = qs.Filter("description__icontains", keyword)
|
||||
}
|
||||
@@ -827,8 +860,99 @@ func (c *BackendReimburseController) Submit() {
|
||||
c.reply(200, "success", nil)
|
||||
}
|
||||
|
||||
// reimburseActPayload 审批操作请求体。
|
||||
type reimburseActPayload struct {
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// readReimburseActPayload 读取审批意见(可空)。
|
||||
func (c *BackendReimburseController) readReimburseActPayload() string {
|
||||
var payload reimburseActPayload
|
||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||
if err == nil && len(raw) > 0 {
|
||||
_ = json.Unmarshal(raw, &payload)
|
||||
}
|
||||
return strings.TrimSpace(payload.Comment)
|
||||
}
|
||||
|
||||
// Approve POST /backend/reimbursements/approve/:id 通过当前审批节点(body: {comment})。
|
||||
func (c *BackendReimburseController) Approve() {
|
||||
claims, ok := c.claims()
|
||||
if !ok {
|
||||
c.reply(401, "未登录或无权限", nil)
|
||||
return
|
||||
}
|
||||
id, ok := c.getID()
|
||||
if !ok {
|
||||
c.reply(400, "无效ID", nil)
|
||||
return
|
||||
}
|
||||
code, msg, hint := reimburseActCore(claims, id, true, c.readReimburseActPayload())
|
||||
if msg != "" {
|
||||
c.reply(code, msg, nil)
|
||||
return
|
||||
}
|
||||
c.reply(200, hint, nil)
|
||||
}
|
||||
|
||||
// Reject POST /backend/reimbursements/reject/:id 驳回报销单(body: {comment})。
|
||||
func (c *BackendReimburseController) Reject() {
|
||||
claims, ok := c.claims()
|
||||
if !ok {
|
||||
c.reply(401, "未登录或无权限", nil)
|
||||
return
|
||||
}
|
||||
id, ok := c.getID()
|
||||
if !ok {
|
||||
c.reply(400, "无效ID", nil)
|
||||
return
|
||||
}
|
||||
code, msg, hint := reimburseActCore(claims, id, false, c.readReimburseActPayload())
|
||||
if msg != "" {
|
||||
c.reply(code, msg, nil)
|
||||
return
|
||||
}
|
||||
c.reply(200, hint, nil)
|
||||
}
|
||||
|
||||
// MyApproveTask GET /backend/reimbursements/my-approve-task/:id
|
||||
// 查当前用户在该单据上的待审批任务:返回 {task_id, node_name},无待办返回 data=null。
|
||||
func (c *BackendReimburseController) MyApproveTask() {
|
||||
claims, ok := c.claims()
|
||||
if !ok {
|
||||
c.reply(401, "未登录或无权限", nil)
|
||||
return
|
||||
}
|
||||
id, ok := c.getID()
|
||||
if !ok {
|
||||
c.reply(400, "无效ID", nil)
|
||||
return
|
||||
}
|
||||
task, err := reimburseMyTaskCore(claims, id)
|
||||
if err != nil {
|
||||
c.reply(500, "查询审批任务失败", nil)
|
||||
return
|
||||
}
|
||||
if task == nil {
|
||||
c.reply(200, "success", nil)
|
||||
return
|
||||
}
|
||||
c.reply(200, "success", map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"node_name": task.NodeName,
|
||||
})
|
||||
}
|
||||
|
||||
// reimburseFallbackBizTypes 报销匹配审批流的单据类型回退顺序:
|
||||
// 先按报销专有流(biz_type=reimburse),未配置时回退到 OA 分类默认流
|
||||
// (FlowSave 未显式指定单据类型时 biz_type=module_code,即 oa)。
|
||||
var reimburseFallbackBizTypes = []string{models.ApprovalBizReimburse, "oa"}
|
||||
|
||||
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流,
|
||||
// 提交后状态置为审批中(1),并写入 action=1 的审批记录。
|
||||
// 提交后状态置为审批中(1)并写入 action=1 的审批记录。
|
||||
// 流程匹配与实例/任务创建走运行期引擎(services/approval):
|
||||
// 实例落 yz_approval_instance,首节点待办落 yz_approval_task,
|
||||
// 审批人在「我的待办」里处理(reimburseActCore)。
|
||||
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
||||
row, err := findOwnedReimbursement(claims, id)
|
||||
if err != nil {
|
||||
@@ -837,31 +961,189 @@ 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 {
|
||||
|
||||
var deptID uint64
|
||||
if row.DepartmentID != nil {
|
||||
deptID = *row.DepartmentID
|
||||
}
|
||||
|
||||
// 依次尝试回退类型匹配流程;有部门限制的流程未命中时由引擎继续尝试通用流
|
||||
var instID uint64
|
||||
var submitErr error
|
||||
for _, bizType := range reimburseFallbackBizTypes {
|
||||
instID, submitErr = approval.Submit(approval.SubmitInput{
|
||||
Scope: models.ApprovalScopeTenant,
|
||||
Tid: uint64(claims.TenantId),
|
||||
BizTid: uint64(claims.TenantId),
|
||||
BizType: bizType,
|
||||
BizID: id,
|
||||
BizTitle: claims.Username + "的报销单",
|
||||
SubmitUID: uint64(claims.UserID),
|
||||
SubmitName: claims.Username,
|
||||
DeptID: deptID,
|
||||
})
|
||||
if submitErr == nil {
|
||||
break
|
||||
}
|
||||
var noFlow *approval.ErrNoFlow
|
||||
if !errors.As(submitErr, &noFlow) {
|
||||
return 400, submitErr.Error()
|
||||
}
|
||||
}
|
||||
if submitErr != nil {
|
||||
return 400, "尚未配置审批流程"
|
||||
}
|
||||
|
||||
// 实例可能无审批节点直接通过(如流程只有通知),按实例实际状态落单据状态
|
||||
var inst models.ApprovalInstance
|
||||
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("id", instID).One(&inst); err != nil {
|
||||
return 500, "查询审批实例失败"
|
||||
}
|
||||
newStatus := int8(1) // 审批中
|
||||
if inst.Status == models.ApprovalInstanceApproved {
|
||||
newStatus = 2
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
if _, err := models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
|
||||
"status": newStatus, "current_approver_id": 0, "update_time": now,
|
||||
}); err != nil {
|
||||
return 500, "提交失败"
|
||||
}
|
||||
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
|
||||
if _, 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,
|
||||
})
|
||||
if err != nil {
|
||||
}); err != nil {
|
||||
return 500, "提交审批记录失败"
|
||||
}
|
||||
return 200, ""
|
||||
}
|
||||
|
||||
// findRunningReimburseInstance 按报销单 ID 查运行中的审批实例
|
||||
// (实例的 biz_type 取发起时命中的类型,按回退顺序逐一查找)。
|
||||
func findRunningReimburseInstance(tid, id uint64) (*models.ApprovalInstance, error) {
|
||||
for _, bizType := range reimburseFallbackBizTypes {
|
||||
var inst models.ApprovalInstance
|
||||
err := models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||
Filter("tid", tid).Filter("biz_type", bizType).Filter("biz_id", id).
|
||||
Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).
|
||||
OrderBy("-id").One(&inst)
|
||||
if err == nil {
|
||||
return &inst, nil
|
||||
}
|
||||
if err != orm.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, orm.ErrNoRows
|
||||
}
|
||||
|
||||
// reimburseMyTaskCore 查当前用户在指定报销单上的待审批任务(无则返回 nil)。
|
||||
func reimburseMyTaskCore(claims *jwtutil.Claims, id uint64) (*models.ApprovalTask, error) {
|
||||
inst, err := findRunningReimburseInstance(uint64(claims.TenantId), id)
|
||||
if err != nil {
|
||||
return nil, nil // 无运行中实例:不是「错误」,只是没有待办
|
||||
}
|
||||
var task models.ApprovalTask
|
||||
err = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", inst.ID).
|
||||
Filter("approver_id", claims.UserID).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Filter("is_deleted", 0).
|
||||
One(&task)
|
||||
if err == orm.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// reimburseActCore 审批操作共享逻辑:当前用户必须是该单当前节点的待办审批人,
|
||||
// 通过/驳回经运行期引擎推进(或签/会签/多级节点均由引擎处理),
|
||||
// 实例结束时同步报销单状态,并写审批记录保持时间线兼容。
|
||||
// 返回 (HTTP 状态码, 错误信息, 成功提示),错误信息为空串表示成功。
|
||||
func reimburseActCore(claims *jwtutil.Claims, id uint64, pass bool, comment string) (int, string, string) {
|
||||
inst, err := findRunningReimburseInstance(uint64(claims.TenantId), id)
|
||||
if err != nil {
|
||||
return 404, "该报销单不在审批中", ""
|
||||
}
|
||||
|
||||
var task models.ApprovalTask
|
||||
err = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||
Filter("instance_id", inst.ID).
|
||||
Filter("approver_id", claims.UserID).
|
||||
Filter("status", models.ApprovalTaskPending).
|
||||
Filter("is_deleted", 0).
|
||||
One(&task)
|
||||
if err == orm.ErrNoRows {
|
||||
return 400, "您没有该单据的待审批任务", ""
|
||||
}
|
||||
if err != nil {
|
||||
return 500, "查询审批任务失败", ""
|
||||
}
|
||||
|
||||
result, err := approval.Approve(task.ID, approval.ApproveInput{
|
||||
ApproverID: uint64(claims.UserID),
|
||||
Pass: pass,
|
||||
Comment: comment,
|
||||
})
|
||||
if err != nil {
|
||||
return 400, err.Error(), ""
|
||||
}
|
||||
|
||||
// 业务回调:写时间线记录 + 实例结束时同步单据状态
|
||||
applyReimburseApprovalResult(uint64(claims.TenantId), id,
|
||||
uint64(claims.UserID), claims.Username, task.Sort,
|
||||
pass, comment, result.Finished, result.InstanceStat)
|
||||
|
||||
hint := result.Message
|
||||
if hint == "" {
|
||||
hint = "操作成功"
|
||||
}
|
||||
return 200, "", hint
|
||||
}
|
||||
|
||||
// isReimburseBizType 业务类型是否归属报销(提交与实例落库的 biz_type 均可能命中)。
|
||||
func isReimburseBizType(bizType string) bool {
|
||||
for _, bt := range reimburseFallbackBizTypes {
|
||||
if bt == bizType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// applyReimburseApprovalResult 报销业务回调(报销页与审批中心两处审批入口共用):
|
||||
// - 始终写一条审批记录(action: 2通过 3驳回),与既有时间线保持兼容;
|
||||
// - 实例结束时同步报销单状态:通过→2,驳回→3。
|
||||
func applyReimburseApprovalResult(tid, bizID, approverID uint64, approverName string,
|
||||
taskSort int, pass bool, comment string, finished bool, instStat int8) {
|
||||
|
||||
action := int8(models.ApprovalActionApprove)
|
||||
if !pass {
|
||||
action = models.ApprovalActionReject
|
||||
}
|
||||
if _, err := models.Orm.Insert(&models.BackendApprovalRecord{
|
||||
Tid: tid, UID: uint64Ptr(approverID), ReimbursementID: bizID,
|
||||
ApproverID: approverID, ApproverName: approverName, Action: action,
|
||||
Comment: &comment, Step: int8(taskSort),
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if finished {
|
||||
newStatus := int8(3)
|
||||
if instStat == models.ApprovalInstanceApproved {
|
||||
newStatus = 2
|
||||
}
|
||||
_, _ = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", bizID).
|
||||
Update(orm.Params{"status": newStatus, "update_time": time.Now()})
|
||||
}
|
||||
}
|
||||
|
||||
type reimbursementStatusPayload struct {
|
||||
Status int8 `json:"status"`
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ func Register() {
|
||||
beego.Router("/api/oa/expense/item/update/:id/:item_id", &controllers.AppOaController{}, "post:ExpenseUpdateItem")
|
||||
beego.Router("/api/oa/expense/item/delete/:id/:item_id", &controllers.AppOaController{}, "delete:ExpenseDeleteItem")
|
||||
beego.Router("/api/oa/expense/submit/:id", &controllers.AppOaController{}, "post:ExpenseSubmit")
|
||||
beego.Router("/api/oa/expense/approve/:id", &controllers.AppOaController{}, "post:ExpenseApprove")
|
||||
beego.Router("/api/oa/expense/reject/:id", &controllers.AppOaController{}, "post:ExpenseReject")
|
||||
beego.Router("/api/oa/expense/my-approve-task/:id", &controllers.AppOaController{}, "get:ExpenseMyApproveTask")
|
||||
beego.Router("/api/oa/expense/status/:id", &controllers.AppOaController{}, "post:ExpenseStatus")
|
||||
beego.Router("/api/oa/expense/records/:id", &controllers.AppOaController{}, "get:ExpenseRecords")
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ func RegisterApprovalRoutes() {
|
||||
beego.Router("/backend/approval/flow/delete/:id", &controllers.BackendApprovalController{}, "delete:FlowDelete")
|
||||
beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick")
|
||||
beego.Router("/backend/approval/flow/available", &controllers.BackendApprovalController{}, "get:AvailableFlows")
|
||||
|
||||
// 审批中心:待我审批/我已审批/抄送我的/我发起的 + 通用审批操作 + 实例进度
|
||||
beego.Router("/backend/approval/center/task/list", &controllers.ApprovalCenterController{}, "get:TaskList")
|
||||
beego.Router("/backend/approval/center/task/act", &controllers.ApprovalCenterController{}, "post:TaskAct")
|
||||
beego.Router("/backend/approval/center/instance/list", &controllers.ApprovalCenterController{}, "get:InstanceList")
|
||||
beego.Router("/backend/approval/center/instance/detail/:id", &controllers.ApprovalCenterController{}, "get:InstanceDetail")
|
||||
}
|
||||
|
||||
// RegisterProductRoutes 注册租户端「产品购买」路由。
|
||||
@@ -296,6 +302,9 @@ func RegisterAuthRoutes() {
|
||||
beego.Router("/backend/reimbursements/types/:type_id", &controllers.BackendReimburseController{}, "post:UpdateType;delete:DeleteType")
|
||||
beego.Router("/backend/reimbursements/types", &controllers.BackendReimburseController{}, "get:Types;post:CreateType")
|
||||
beego.Router("/backend/reimbursements/flows", &controllers.BackendReimburseController{}, "get:Flows")
|
||||
beego.Router("/backend/reimbursements/approve/:id", &controllers.BackendReimburseController{}, "post:Approve")
|
||||
beego.Router("/backend/reimbursements/reject/:id", &controllers.BackendReimburseController{}, "post:Reject")
|
||||
beego.Router("/backend/reimbursements/my-approve-task/:id", &controllers.BackendReimburseController{}, "get:MyApproveTask")
|
||||
beego.Router("/backend/reimbursements/:id/items", &controllers.BackendReimburseController{}, "post:AddItem")
|
||||
beego.Router("/backend/reimbursements/:id/items/:item_id", &controllers.BackendReimburseController{}, "post:UpdateItem;delete:DeleteItem")
|
||||
beego.Router("/backend/reimbursements/:id", &controllers.BackendReimburseController{}, "get:Detail;post:Update;delete:Delete")
|
||||
|
||||
Reference in new issue
Block a user