This commit is contained in:
hero920103 committed 2026-10-17 11:59:54 +08:00
commit 699a117678
28 files changed
+4428 -970

No files matched your search

+859
View File
@@ -0,0 +1,859 @@
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 任务视图(移动端流程时间线用)
// nodeType 来自流程定义(开始/审批/通知/结束),取不到时按审批节点处理。
func oaApprovalTaskView(t models.ApprovalTask, isCurrent bool, nodeType int8) 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")
}
if nodeType == 0 {
nodeType = models.ApprovalNodeApproval
}
return map[string]interface{}{
"id": t.ID,
"node_key": t.NodeKey,
"node_name": t.NodeName,
"node_type": nodeType,
"approver_id": t.ApproverID,
"approver_name": t.ApproverName,
"approver_sub": oaApprovalUserOrgName(t.Tid, t.ApproverID),
"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"),
}
}
// oaApprovalUserOrgName 审批人所属部门名,用作流程节点副标题(如「财务部」),取不到返回空串。
func oaApprovalUserOrgName(tid, uid uint64) string {
if tid == 0 || uid == 0 {
return ""
}
var tu models.SystemTenantUser
if err := models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).Filter("uid", uid).
Filter("delete_time__isnull", true).One(&tu); err != nil {
return ""
}
if tu.OrgID == 0 {
return ""
}
var org models.BackendOrganization
if err := models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("id", tu.OrgID).One(&org); err != nil {
return ""
}
return org.OrgName
}
// 时间线虚拟节点的 node_key:用于标识流程的开始 / 结束,前端据此区分渲染。
const (
oaApprovalStartKey = "__start__"
oaApprovalEndKey = "__end__"
)
// oaApprovalFlowTimeline 组装移动端「审批流程」时间线:
// 首尾固定为提交申请(开始)与流程结束(结束),中间是流程产生的节点任务,
// 让发起人一眼看出流程当前走到哪、是否已经走完。
func oaApprovalFlowTimeline(inst models.ApprovalInstance, tasks []models.ApprovalTask) []map[string]interface{} {
nodeTypes := oaApprovalNodeTypes(inst)
timeline := make([]map[string]interface{}, 0, len(tasks)+2)
timeline = append(timeline, oaApprovalStartNode(inst))
for _, t := range tasks {
timeline = append(timeline, oaApprovalTaskView(t, t.NodeKey == inst.CurrentNode, nodeTypes[t.NodeKey]))
}
timeline = append(timeline, oaApprovalEndNode(inst))
return timeline
}
// oaApprovalNodeTypes 读流程定义,取各节点类型(开始/审批/通知/结束)。
// 流程被删除或改版时返回空表,调用方按审批节点兜底。
func oaApprovalNodeTypes(inst models.ApprovalInstance) map[string]int8 {
out := make(map[string]int8)
flow, err := approvalEngine.LoadFlow(inst.Scope, inst.Tid, inst.BizType, inst.FlowID)
if err != nil {
return out
}
for i := range flow.Nodes {
out[flow.Nodes[i].NodeKey] = flow.Nodes[i].NodeType
}
return out
}
// oaApprovalStartNode 虚拟开始节点:即发起人提交动作。
func oaApprovalStartNode(inst models.ApprovalInstance) map[string]interface{} {
timeStr := ""
if inst.SubmitTime != nil {
timeStr = inst.SubmitTime.Format("2006-01-02 15:04:05")
} else if !inst.CreateTime.IsZero() {
timeStr = inst.CreateTime.Format("2006-01-02 15:04:05")
}
return map[string]interface{}{
"id": 0,
"node_key": oaApprovalStartKey,
"node_name": "提交申请",
"node_type": models.ApprovalNodeStart,
"approver_id": inst.SubmitUserID,
"approver_name": inst.SubmitUserName,
"approver_sub": oaApprovalUserOrgName(inst.Tid, inst.SubmitUserID),
"multi_mode": 1,
"status": models.ApprovalTaskApproved,
"status_label": "已提交",
"status_tag": "success",
"action": 0,
"comment": "",
"is_current": false,
"deadline": "",
"finish_time": timeStr,
"create_time": timeStr,
}
}
// oaApprovalEndNode 虚拟结束节点:实例运行中为「待完成」,结束后展示最终结果与完成时间。
func oaApprovalEndNode(inst models.ApprovalInstance) map[string]interface{} {
finishTime := ""
if inst.FinishTime != nil {
finishTime = inst.FinishTime.Format("2006-01-02 15:04:05")
}
end := map[string]interface{}{
"id": 0,
"node_key": oaApprovalEndKey,
"node_name": "流程结束",
"node_type": models.ApprovalNodeEnd,
"approver_id": 0,
"approver_name": "",
"multi_mode": 1,
"status": models.ApprovalTaskPending,
"status_label": "待完成",
"status_tag": "info",
"action": 0,
"comment": "",
"is_current": false,
"deadline": "",
"finish_time": finishTime,
"create_time": finishTime,
}
switch inst.Status {
case models.ApprovalInstanceApproved:
end["status"] = models.ApprovalTaskApproved
end["status_label"] = "已通过"
end["status_tag"] = "success"
case models.ApprovalInstanceRejected:
end["status"] = models.ApprovalTaskRejected
end["status_label"] = "已驳回"
end["status_tag"] = "error"
case models.ApprovalInstanceWithdrawn:
end["status"] = models.ApprovalTaskSkipped
end["status_label"] = "已撤回"
end["status_tag"] = "info"
case models.ApprovalInstanceCanceled:
end["status"] = models.ApprovalTaskSkipped
end["status_label"] = "已取消"
end["status_tag"] = "info"
}
return end
}
// ============================ 路由方法 ============================
// 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, oaApprovalNodeTypes(inst)[t.NodeKey])
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 := oaApprovalFlowTimeline(inst, tasks)
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)})
}
+2 -1
View File
@@ -256,7 +256,8 @@ func (c *AppOaController) ExpenseSubmit() {
c.oaOk(nil)
}
// ExpenseStatus POST /api/oa/expense/status/:id 手动确认更改单据状态(body: {status})。
// ExpenseStatus POST /api/oa/expense/status/:id 报销人确认款项到账(body: {status:5})。
// 仅限「已通过」的单据确认收款,其它状态由审批引擎推进。
func (c *AppOaController) ExpenseStatus() {
claims, err := c.oaClaims()
if err != nil {
+340
View File
@@ -0,0 +1,340 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"time"
"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"
)
// BackendApprovalRuntimeController Web 后台「审批中心」运行时接口。
//
// 与移动端 /api/oa/approval/* 共用同一套审批引擎与视图函数,差别只在鉴权:
// 移动端走 oaClaims(backend/app/platform 三类 token),后台只认 backend token。
//
// 接口清单:
//
// GET /backend/approval/instance/mine 我发起的审批
// GET /backend/approval/instance/todo 待我审批(handled=1 返回我已处理)
// GET /backend/approval/instance/detail/:id 审批详情(含流程时间线)
// POST /backend/approval/instance/approve 通过 / 驳回
// POST /backend/approval/instance/withdraw/:id 发起人撤回
type BackendApprovalRuntimeController struct {
beego.Controller
}
func (c *BackendApprovalRuntimeController) fail(httpStatus, bizCode int, msg string) {
jsonErr(&c.Controller, httpStatus, bizCode, msg)
}
func (c *BackendApprovalRuntimeController) ok(data interface{}) {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": data}
_ = c.ServeJSON()
}
func (c *BackendApprovalRuntimeController) idParam() uint64 {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil {
return 0
}
return id
}
// approvalRuntimeUID 当前登录用户在租户侧的 UID(审批人以 yz_system_tenant_user.id 记录)
func approvalRuntimeUID(claims *jwtutil.Claims) uint64 {
return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
}
// ============================ 我发起的 ============================
// InstanceMine GET /backend/approval/instance/mine?page=&pageSize=&keyword=&status=
// 我发起的审批实例列表。
func (c *BackendApprovalRuntimeController) InstanceMine() {
claims, err := requireBackend(&c.Controller)
if err != nil {
c.fail(401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
uid := approvalRuntimeUID(claims)
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", tid).
Filter("submit_user_id", uid).
Filter("is_deleted", 0)
if keyword := strings.TrimSpace(c.GetString("keyword")); keyword != "" {
qs = qs.Filter("biz_title__icontains", keyword)
}
if status, err := c.GetInt("status"); err == nil {
qs = qs.Filter("status", status)
}
total, _ := qs.Count()
var rows []models.ApprovalInstance
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
if err != nil && err != orm.ErrNoRows {
c.fail(500, 500, "查询失败")
return
}
list := make([]map[string]interface{}, 0, len(rows))
for _, row := range rows {
view := oaApprovalInstanceView(row)
view["flow_name"] = approvalRuntimeFlowName(tid, row.FlowID)
list = append(list, view)
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// ============================ 待我审批 ============================
// InstanceTodo GET /backend/approval/instance/todo?handled=0|1
// 我作为审批人的任务列表,默认取待办;handled=1 时返回我已处理过的任务。
func (c *BackendApprovalRuntimeController) InstanceTodo() {
claims, err := requireBackend(&c.Controller)
if err != nil {
c.fail(401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
uid := approvalRuntimeUID(claims)
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 10)
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
handled, _ := c.GetInt("handled", 0)
qs := models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("scope", models.ApprovalScopeTenant).
Filter("tid", tid).
Filter("approver_id", uid).
Filter("is_deleted", 0)
if handled == 1 {
qs = qs.Filter("status__in", models.ApprovalTaskApproved, models.ApprovalTaskRejected,
models.ApprovalTaskSkipped, models.ApprovalTaskTimeout)
} else {
qs = qs.Filter("status", models.ApprovalTaskPending)
}
total, _ := qs.Count()
var tasks []models.ApprovalTask
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&tasks)
if err != nil && err != orm.ErrNoRows {
c.fail(500, 500, "查询失败")
return
}
list := make([]map[string]interface{}, 0, len(tasks))
for _, t := range tasks {
var inst models.ApprovalInstance
if models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("id", t.InstanceID).Filter("is_deleted", 0).One(&inst) != nil {
continue
}
// 待办列表只保留仍在审批中的实例,已结束的实例不再展示为待办
if handled == 0 && inst.Status != models.ApprovalInstanceRunning {
continue
}
taskView := oaApprovalTaskView(t, true, oaApprovalNodeTypes(inst)[t.NodeKey])
taskView["instance"] = oaApprovalInstanceView(inst)
list = append(list, taskView)
}
c.ok(map[string]interface{}{"list": list, "total": total, "page": page, "pageSize": pageSize})
}
// ============================ 详情 ============================
// InstanceDetail GET /backend/approval/instance/detail/:id
// 返回实例信息 + 首尾齐备的流程时间线(复用移动端同一套视图)。
func (c *BackendApprovalRuntimeController) InstanceDetail() {
claims, err := requireBackend(&c.Controller)
if err != nil {
c.fail(401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
uid := approvalRuntimeUID(claims)
id := c.idParam()
if id == 0 {
c.fail(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.fail(404, 404, "审批不存在")
return
}
tasks := oaApprovalTasksByInstance(inst.ID)
timeline := oaApprovalFlowTimeline(inst, tasks)
// 当前用户可操作的任务(存在即说明本人是当前审批人)
var currentTaskID uint64
canApprove := false
for _, t := range tasks {
if t.ApproverID == uid && t.Status == models.ApprovalTaskPending {
canApprove = true
currentTaskID = t.ID
break
}
}
c.ok(map[string]interface{}{
"item": oaApprovalInstanceView(inst),
"timeline": timeline,
"flow_name": approvalRuntimeFlowName(tid, inst.FlowID),
"can_approve": canApprove,
"can_withdraw": inst.SubmitUserID == uid && inst.Status == models.ApprovalInstanceRunning,
"current_task_id": currentTaskID,
})
}
// ============================ 审批动作 ============================
type approvalRuntimeApprovePayload struct {
TaskID uint64 `json:"task_id"`
Pass bool `json:"pass"`
Comment string `json:"comment"`
}
// Approve POST /backend/approval/instance/approve {task_id, pass, comment}
func (c *BackendApprovalRuntimeController) Approve() {
claims, err := requireBackend(&c.Controller)
if err != nil {
c.fail(401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
uid := approvalRuntimeUID(claims)
var p approvalRuntimeApprovePayload
raw, err := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 && (err != nil || json.Unmarshal(raw, &p) != nil) {
c.fail(400, 400, "参数错误")
return
}
if p.TaskID == 0 {
c.fail(400, 400, "缺少审批任务ID")
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.fail(404, 404, "任务不存在")
return
}
if task.ApproverID != uid {
c.fail(403, 403, "您不是该任务的审批人")
return
}
res, err := approvalEngine.Approve(task.ID, approvalEngine.ApproveInput{
ApproverID: uid,
Pass: p.Pass,
Comment: strings.TrimSpace(p.Comment),
})
if err != nil {
c.fail(400, 400, err.Error())
return
}
c.ok(map[string]interface{}{
"instance_id": res.InstanceID,
"instance_stat": res.InstanceStat,
"finished": res.Finished,
"message": res.Message,
})
}
// Withdraw POST /backend/approval/instance/withdraw/:id 发起人撤回(仅审批中)
func (c *BackendApprovalRuntimeController) Withdraw() {
claims, err := requireBackend(&c.Controller)
if err != nil {
c.fail(401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
uid := approvalRuntimeUID(claims)
id := c.idParam()
if id == 0 {
c.fail(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.fail(404, 404, "审批不存在")
return
}
if inst.SubmitUserID != uid {
c.fail(403, 403, "仅发起人可撤回")
return
}
if inst.Status != models.ApprovalInstanceRunning {
c.fail(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)
c.ok(nil)
}
// approvalRuntimeFlowName 取流程名称(供列表/详情展示),流程被删除时返回空串。
func approvalRuntimeFlowName(tid, flowID uint64) string {
if flowID == 0 {
return ""
}
var f models.TenantApprovalFlow
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", flowID).Filter("tid", tid).One(&f); err != nil {
return ""
}
return f.FlowName
}
+155 -178
View File
@@ -14,7 +14,7 @@ import (
"server/models"
"server/pkg/jwtutil"
"server/services"
"server/services/approval"
approvalEngine "server/services/approval"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
@@ -410,6 +410,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,
@@ -463,9 +465,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()
@@ -948,11 +999,12 @@ func (c *BackendReimburseController) MyApproveTask() {
// (FlowSave 未显式指定单据类型时 biz_type=module_code,即 oa)。
var reimburseFallbackBizTypes = []string{models.ApprovalBizReimburse, "oa"}
// reimburseSubmitCore 提交审批共享逻辑:仅草稿可提交,需租户已配置启用审批流,
// 提交后状态置为审批中(1)并写入 action=1 的审批记录。
// 流程匹配与实例/任务创建走运行期引擎(services/approval):
// 实例落 yz_approval_instance,首节点待办落 yz_approval_task,
// 审批人在「我的待办」里处理(reimburseActCore)。
// 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 {
@@ -961,203 +1013,122 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
if row.Status != 0 {
return 400, "当前状态不可提交"
}
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 {
tid := uint64(claims.TenantId)
flowID := findReimburseFlowID(tid)
if flowID == 0 {
return 400, "尚未配置审批流程"
}
// 实例可能无审批节点直接通过(如流程只有通知),按实例实际状态落单据状态
var inst models.ApprovalInstance
if err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("id", instID).One(&inst); err != nil {
return 500, "查询审批实例失败"
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()
}
newStatus := int8(1) // 审批中
if inst.Status == models.ApprovalInstanceApproved {
newStatus = 2
// 流程未配置审批节点时引擎会直接通过,此时报销单同步为已通过(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()
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 {
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": status, "current_step": 0, "current_approver_id": 0, "update_time": now,
})
if err != nil {
return 500, "提交失败"
}
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,
}); err != nil {
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
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, "提交审批记录失败"
}
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
}
// 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
}
return nil, orm.ErrNoRows
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
}
// 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 // 无运行中实例:不是「错误」,只是没有待办
// 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
}
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
return tu.OrgID
}
// 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, "该报销单不在审批中", ""
// reimburseBizTitle 审批标题:优先报销说明,其次带单据号。
func reimburseBizTitle(row *models.BackendReimbursement) string {
if desc := reimburseDescription(row); desc != "" {
return desc
}
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
return fmt.Sprintf("报销单 #%d", row.ID)
}
// 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()})
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"`
}
var manualConfirmStatuses = map[int8]bool{
0: true, // 草稿
1: true, // 审批中
2: true, // 已通过
3: true, // 已驳回
4: true, // 已撤回
5: true, // 已打款
}
// 报销单状态(0草稿 1审批中 2已通过 3已驳回 4已撤回 5已打款)
const (
reimbursementStatusApproved int8 = 2 // 已通过
reimbursementStatusPaid int8 = 5 // 已打款
)
// UpdateStatus POST /backend/reimbursements/:id/status — 手动确认更改单据状态
// UpdateStatus POST /backend/reimbursements/:id/status — 报销人确认款项到账(置为「已打款」)。
// 其它状态均由审批引擎/流程动作推进,不再支持手工变更。
func (c *BackendReimburseController) UpdateStatus() {
claims, ok := c.claims()
if !ok {
@@ -1187,27 +1158,33 @@ func (c *BackendReimburseController) UpdateStatus() {
c.reply(200, "success", map[string]interface{}{"status": newStatus})
}
// reimburseUpdateStatusCore 手动确认更改单据状态共享逻辑。
// reimburseUpdateStatusCore 报销人确认款项到账共享逻辑。
//
// 规则:审批通过后,报销金额是否到账由报销人本人确认;确认后单据置为「已打款」。
// 草稿/审批中/已通过/已驳回/已撤回等状态一律由流程动作与审批引擎推进,不再支持手工修改。
// 返回 (生效状态, HTTP 状态码, 错误信息)。
func reimburseUpdateStatusCore(claims *jwtutil.Claims, id uint64, status int8) (int8, int, string) {
if !manualConfirmStatuses[status] {
return 0, 400, "不支持的状态值"
if status != reimbursementStatusPaid {
return 0, 400, "仅支持确认收款,其它状态由审批流程自动推进"
}
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 0, 404, "报销单不存在"
}
if row.Status == status {
if row.Status == reimbursementStatusPaid {
return row.Status, 200, ""
}
if row.Status != reimbursementStatusApproved {
return 0, 400, "仅审批通过的报销单可以确认到账"
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.BackendReimbursement)).Filter("id", id).Update(orm.Params{
"status": status, "update_time": now,
"status": reimbursementStatusPaid, "update_time": now,
})
if err != nil {
return 0, 500, "更新状态失败"
}
return status, 200, ""
return reimbursementStatusPaid, 200, ""
}
// Records GET /backend/reimbursements/:id/records