feat: 完善审批退回流程与报销申请关联
后端优化报销单访问策略和常量定义,增强退回原因和审批状态 流转,前端完善退回对话框和审批交互组件,新增报销申请关联 模型,优化文档中心行数据和审批收件箱工具函数,增强引导 流程和会话模型,补充单元测试覆盖。
This commit is contained in:
294
web/src/views/scripts/travelReimbursementApplicationLinkModel.js
Normal file
294
web/src/views/scripts/travelReimbursementApplicationLinkModel.js
Normal file
@@ -0,0 +1,294 @@
|
||||
const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal'])
|
||||
|
||||
const APPLICATION_TYPE_ALIASES = {
|
||||
travel: new Set(['travel', 'travel_application']),
|
||||
meal: new Set([
|
||||
'meal',
|
||||
'entertainment',
|
||||
'meal_application',
|
||||
'entertainment_application',
|
||||
'business_entertainment_application',
|
||||
'hospitality_application'
|
||||
])
|
||||
}
|
||||
|
||||
const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application'])
|
||||
const BLOCKED_APPLICATION_STATUSES = new Set(['draft', 'returned', 'rejected', 'cancelled', 'canceled', 'deleted'])
|
||||
|
||||
const STATUS_LABELS = {
|
||||
submitted: '审批中',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
paid: '已入账'
|
||||
}
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
meal: '业务招待费'
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeLower(value) {
|
||||
return normalizeText(value).toLowerCase()
|
||||
}
|
||||
|
||||
function uniqueValues(values) {
|
||||
return Array.from(new Set((Array.isArray(values) ? values : []).map(normalizeText).filter(Boolean)))
|
||||
}
|
||||
|
||||
function normalizeClaimNo(claim) {
|
||||
return normalizeText(claim?.claim_no || claim?.claimNo).toUpperCase()
|
||||
}
|
||||
|
||||
function normalizeExpenseType(claim) {
|
||||
return normalizeLower(claim?.expense_type || claim?.expenseType || claim?.type_code || claim?.typeCode)
|
||||
}
|
||||
|
||||
function normalizeApplicationStatus(claim) {
|
||||
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
|
||||
}
|
||||
|
||||
function normalizeDocumentType(claim) {
|
||||
return normalizeLower(
|
||||
claim?.document_type_code
|
||||
|| claim?.documentTypeCode
|
||||
|| claim?.document_type
|
||||
|| claim?.documentType
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeApplicationDate(claim) {
|
||||
return normalizeText(
|
||||
claim?.submitted_at
|
||||
|| claim?.submittedAt
|
||||
|| claim?.created_at
|
||||
|| claim?.createdAt
|
||||
|| claim?.occurred_at
|
||||
|| claim?.occurredAt
|
||||
)
|
||||
}
|
||||
|
||||
function toTimestamp(value) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
||||
}
|
||||
|
||||
function formatAmount(value) {
|
||||
const numberValue = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0) {
|
||||
return ''
|
||||
}
|
||||
return `¥${new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: Number.isInteger(numberValue) ? 0 : 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(numberValue)}`
|
||||
}
|
||||
|
||||
function includesAny(text, keywords) {
|
||||
const normalized = normalizeText(text)
|
||||
return keywords.some((keyword) => normalized.includes(keyword))
|
||||
}
|
||||
|
||||
function buildApplicationKeywordText(claim) {
|
||||
return [
|
||||
claim?.reason,
|
||||
claim?.business_reason,
|
||||
claim?.title,
|
||||
claim?.summary,
|
||||
claim?.description,
|
||||
claim?.location,
|
||||
claim?.business_location,
|
||||
claim?.expense_type_label,
|
||||
claim?.expenseTypeLabel
|
||||
].map(normalizeText).filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function matchesGenericApplicationByText(claim, expenseType) {
|
||||
const haystack = buildApplicationKeywordText(claim)
|
||||
if (expenseType === 'travel') {
|
||||
return includesAny(haystack, ['差旅', '出差', '住宿', '交通', '行程'])
|
||||
}
|
||||
if (expenseType === 'meal') {
|
||||
return includesAny(haystack, ['招待', '客户', '接待', '宴请', '用餐', '餐饮'])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function requiresApplicationBeforeReimbursement(expenseType) {
|
||||
return REQUIRED_APPLICATION_EXPENSE_TYPES.has(normalizeLower(expenseType))
|
||||
}
|
||||
|
||||
export function getRequiredApplicationExpenseLabel(expenseType) {
|
||||
return EXPENSE_TYPE_LABELS[normalizeLower(expenseType)] || '报销'
|
||||
}
|
||||
|
||||
export function isExpenseApplicationClaim(claim) {
|
||||
const documentType = normalizeDocumentType(claim)
|
||||
const expenseType = normalizeExpenseType(claim)
|
||||
const claimNo = normalizeClaimNo(claim)
|
||||
|
||||
return documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| expenseType === 'application'
|
||||
|| expenseType.endsWith('_application')
|
||||
}
|
||||
|
||||
export function matchesRequiredApplicationExpenseType(claim, expenseType) {
|
||||
const normalizedExpenseType = normalizeLower(expenseType)
|
||||
const claimExpenseType = normalizeExpenseType(claim)
|
||||
const aliases = APPLICATION_TYPE_ALIASES[normalizedExpenseType] || new Set()
|
||||
|
||||
if (aliases.has(claimExpenseType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return GENERIC_APPLICATION_TYPES.has(claimExpenseType)
|
||||
&& matchesGenericApplicationByText(claim, normalizedExpenseType)
|
||||
}
|
||||
|
||||
export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
|
||||
const userIds = uniqueValues([
|
||||
currentUser.id,
|
||||
currentUser.employeeId,
|
||||
currentUser.employee_id,
|
||||
currentUser.employeeNo,
|
||||
currentUser.employee_no,
|
||||
currentUser.username,
|
||||
currentUser.email
|
||||
])
|
||||
const claimIds = uniqueValues([
|
||||
claim?.employee_id,
|
||||
claim?.employeeId,
|
||||
claim?.employee_no,
|
||||
claim?.employeeNo,
|
||||
claim?.username,
|
||||
claim?.user_id,
|
||||
claim?.userId
|
||||
])
|
||||
if (userIds.length && claimIds.length && claimIds.some((item) => userIds.includes(item))) {
|
||||
return true
|
||||
}
|
||||
|
||||
const userNames = uniqueValues([
|
||||
currentUser.name,
|
||||
currentUser.user_name,
|
||||
currentUser.employeeName,
|
||||
currentUser.employee_name,
|
||||
currentUser.username
|
||||
])
|
||||
const claimNames = uniqueValues([
|
||||
claim?.employee_name,
|
||||
claim?.employeeName,
|
||||
claim?.applicant,
|
||||
claim?.applicant_name,
|
||||
claim?.applicantName
|
||||
])
|
||||
if (userNames.length && claimNames.length) {
|
||||
return claimNames.some((item) => userNames.includes(item))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isUsableRequiredApplicationClaim(claim) {
|
||||
const status = normalizeApplicationStatus(claim)
|
||||
return !BLOCKED_APPLICATION_STATUSES.has(status)
|
||||
}
|
||||
|
||||
export function normalizeRequiredApplicationCandidate(claim) {
|
||||
const claimNo = normalizeText(claim?.claim_no || claim?.claimNo)
|
||||
const location = normalizeText(claim?.location || claim?.business_location || claim?.businessLocation)
|
||||
const amountText = formatAmount(claim?.amount || claim?.budget_amount || claim?.budgetAmount)
|
||||
const status = normalizeApplicationStatus(claim)
|
||||
|
||||
return {
|
||||
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
|
||||
claim_no: claimNo,
|
||||
expense_type: normalizeExpenseType(claim),
|
||||
reason: normalizeText(claim?.reason || claim?.business_reason || claim?.description || claim?.title),
|
||||
location,
|
||||
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
|
||||
amount_label: amountText,
|
||||
status,
|
||||
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
|
||||
application_date: normalizeApplicationDate(claim)
|
||||
}
|
||||
}
|
||||
|
||||
export function filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser = {}) {
|
||||
const claims = Array.isArray(claimsPayload)
|
||||
? claimsPayload
|
||||
: Array.isArray(claimsPayload?.items)
|
||||
? claimsPayload.items
|
||||
: Array.isArray(claimsPayload?.claims)
|
||||
? claimsPayload.claims
|
||||
: []
|
||||
|
||||
return claims
|
||||
.filter((claim) => (
|
||||
isExpenseApplicationClaim(claim)
|
||||
&& isUsableRequiredApplicationClaim(claim)
|
||||
&& isClaimOwnedByCurrentUser(claim, currentUser)
|
||||
&& matchesRequiredApplicationExpenseType(claim, expenseType)
|
||||
))
|
||||
.map(normalizeRequiredApplicationCandidate)
|
||||
.sort((left, right) => toTimestamp(right.application_date) - toTimestamp(left.application_date))
|
||||
}
|
||||
|
||||
export function buildRequiredApplicationActions(applications, actionType) {
|
||||
return (Array.isArray(applications) ? applications : []).map((application) => {
|
||||
const claimNo = normalizeText(application.claim_no) || '未编号申请单'
|
||||
const description = [
|
||||
application.status_label,
|
||||
application.location && `地点:${application.location}`,
|
||||
application.amount_label && `预算:${application.amount_label}`,
|
||||
application.reason && `事由:${application.reason}`
|
||||
].filter(Boolean).join(' · ')
|
||||
|
||||
return {
|
||||
label: claimNo,
|
||||
description,
|
||||
icon: 'mdi mdi-file-link-outline',
|
||||
action_type: actionType,
|
||||
payload: {
|
||||
application_claim_id: application.id,
|
||||
application_claim_no: application.claim_no,
|
||||
application_expense_type: application.expense_type,
|
||||
application_reason: application.reason,
|
||||
application_location: application.location,
|
||||
application_amount: application.amount,
|
||||
application_amount_label: application.amount_label,
|
||||
application_status: application.status,
|
||||
application_status_label: application.status_label,
|
||||
application_date: application.application_date
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function buildRequiredApplicationSelectionText(expenseType, applications) {
|
||||
const label = getRequiredApplicationExpenseLabel(expenseType)
|
||||
return [
|
||||
`发起“${label}”报销前,需要先关联对应的申请单。`,
|
||||
'',
|
||||
`我查到 ${applications.length} 个可关联申请单,请先选择其中一个。`,
|
||||
'选择后,我再继续向你收集本次报销依据。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function buildRequiredApplicationMissingText(expenseType) {
|
||||
const label = getRequiredApplicationExpenseLabel(expenseType)
|
||||
return [
|
||||
`发起“${label}”报销前,需要先关联对应的申请单。`,
|
||||
'',
|
||||
`我没有查到你名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`,
|
||||
'请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user