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

+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