feat: 完善审批退回流程与报销申请关联

后端优化报销单访问策略和常量定义,增强退回原因和审批状态
流转,前端完善退回对话框和审批交互组件,新增报销申请关联
模型,优化文档中心行数据和审批收件箱工具函数,增强引导
流程和会话模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-27 14:35:17 +08:00
parent 7d32eae74e
commit cbb98f4469
30 changed files with 1794 additions and 250 deletions

View File

@@ -40,6 +40,21 @@ function normalizeRoleCode(value) {
return roleCode === 'auditor' ? 'budget_monitor' : roleCode
}
function normalizeComparableText(value) {
return String(value || '').trim()
}
function collectIdentityNames(...values) {
return values
.map((value) => normalizeComparableText(value))
.filter(Boolean)
}
function identityIntersects(leftValues, rightValues) {
const rightSet = new Set(rightValues)
return leftValues.some((item) => rightSet.has(item))
}
function hasPlatformAdminIdentity(user) {
if (!user) {
return false
@@ -111,10 +126,53 @@ export function canApproveLeaderExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
request?.employee_name,
request?.profileName,
request?.applicant
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames)
}
export function isCurrentDirectManagerForRequest(request, user) {
if (isCurrentRequestApplicant(request, user)) {
return false
}
const managerNames = collectIdentityNames(
request?.profileManager,
request?.managerName,
request?.manager_name,
request?.directManagerName,
request?.direct_manager_name,
request?.manager
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false

View File

@@ -51,27 +51,86 @@ function getLatestEvent(events) {
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
}
export function findLeaderApprovalEvent(request) {
return getLatestEvent(
getRiskFlags(request).filter((flag) => {
const source = normalizeText(flag?.source)
const eventType = normalizeText(flag?.event_type || flag?.eventType)
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
return (
source === 'manual_approval'
&& (
eventType === 'expense_application_approval'
|| previousStage.includes('直属领导')
|| previousStage.includes('领导审批')
|| nextStage.includes('财务')
|| nextStage.includes('审批完成')
)
)
})
function isLeaderApprovalEvent(flag) {
const source = normalizeText(flag?.source)
const eventType = normalizeText(flag?.event_type || flag?.eventType)
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
return (
source === 'manual_approval'
&& (
eventType === 'expense_application_approval'
|| previousStage.includes('直属领导')
|| previousStage.includes('领导审批')
|| nextStage.includes('财务')
|| nextStage.includes('审批完成')
)
)
}
function isLeaderReturnEvent(flag) {
const source = normalizeText(flag?.source)
const eventType = normalizeText(flag?.event_type || flag?.eventType)
const returnStage = normalizeText(flag?.return_stage || flag?.returnStage || flag?.previous_approval_stage)
const stageKey = normalizeText(flag?.return_stage_key || flag?.returnStageKey)
return (
source === 'manual_return'
&& (
eventType === 'expense_application_return'
|| stageKey === 'direct_manager'
|| returnStage.includes('直属领导')
|| returnStage.includes('领导审批')
)
)
}
export function findLeaderApprovalEvent(request) {
return getLatestEvent(getRiskFlags(request).filter(isLeaderApprovalEvent))
}
export function buildLeaderApprovalEvents(request) {
return getRiskFlags(request)
.filter((flag) => isLeaderApprovalEvent(flag) || isLeaderReturnEvent(flag))
.map((event) => {
const returned = isLeaderReturnEvent(event)
const rawTime = event.created_at || event.createdAt
const operator = resolveDisplayName(
event.operator,
event.operator_name,
event.operatorName,
request?.profileManager,
request?.managerName
) || '直属领导'
const time = formatDateTime(rawTime)
const opinion = normalizeText(event.opinion)
|| normalizeText(event.leader_opinion || event.leaderOpinion)
|| normalizeText(event.reason)
|| normalizeText(event.message)
|| (returned ? '已退回申请,请申请人补充后重新提交。' : '已审批通过。')
const returnCount = Number(event.return_count || event.returnCount || 0)
return {
id: normalizeText(event.return_event_id || event.returnEventId || event.approval_event_id || event.approvalEventId)
|| `${returned ? 'return' : 'approval'}-${event.created_at || event.createdAt || opinion}`,
type: returned ? 'returned' : 'approved',
tone: returned ? 'danger' : 'success',
title: returned ? '领导退回' : '领导审批通过',
operator,
time,
sortAt: rawTime,
opinion,
returnCount,
meta: [operator ? `${operator}${returned ? '退回' : '通过'}` : '', time].filter(Boolean).join(' · ')
}
})
.sort((left, right) => {
const leftDate = toDate(left.sortAt)
const rightDate = toDate(right.sortAt)
if (!leftDate || !rightDate) return 0
return leftDate.getTime() - rightDate.getTime()
})
.map(({ sortAt, ...event }) => event)
}
export function buildLeaderApprovalInfo(request) {
const event = findLeaderApprovalEvent(request)
if (!event) {

View File

@@ -1,23 +1,18 @@
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isFinanceUser
} from './accessControl.js'
export function canProcessApprovalRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
const currentName = String(currentUser?.name || '').trim()
const applicantName = String(request?.person || request?.employeeName || '').trim()
if (currentName && applicantName && currentName === applicantName) {
if (isCurrentRequestApplicant(request, currentUser)) {
return false
}
if (canManageExpenseClaims(currentUser)) {
return true
}
if (isFinanceUser(currentUser) && node.includes('财务')) {
return true
}
@@ -29,7 +24,11 @@ export function canProcessApprovalRequest(request, currentUser) {
|| node.includes('负责人审批')
)
return canApproveLeaderExpenseClaims(currentUser) && isLeaderApprovalNode
return (
canApproveLeaderExpenseClaims(currentUser)
&& isLeaderApprovalNode
&& isCurrentDirectManagerForRequest(request, currentUser)
)
}
export function listPendingApprovalRequests(claimsPayload, currentUser) {

View File

@@ -45,3 +45,22 @@ export function isArchivedDocumentRow(row) {
export function excludeArchivedDocumentRows(rows) {
return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row))
}
export function isApplicationApprovalRow(row) {
if (!row) {
return false
}
const statusGroup = String(row.statusGroup || '').trim()
return statusGroup === 'in_progress' && isApplicationRequestLike(row.rawRequest || row)
}
export function filterApplicationScopeNewRows(rows) {
return (Array.isArray(rows) ? rows : []).filter((row) => !isApplicationApprovalRow(row))
}
export function prepareApplicationScopeRows(rows) {
return (Array.isArray(rows) ? rows : [])
.filter((row) => isApplicationRequestLike(row.rawRequest || row))
.map((row) => (isApplicationApprovalRow(row) ? { ...row, isNewDocument: false } : row))
}