feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
@@ -20,6 +20,7 @@ const VIEW_ROLE_RULES = {
|
||||
settings: ['manager']
|
||||
}
|
||||
const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive'])
|
||||
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
|
||||
|
||||
function normalizedRoleCodes(user) {
|
||||
if (!user) {
|
||||
@@ -51,6 +52,14 @@ export function canManageExpenseClaims(user) {
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canReturnExpenseClaims(user) {
|
||||
if (Boolean(user?.isAdmin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
return false
|
||||
|
||||
40
web/src/utils/approvalInbox.js
Normal file
40
web/src/utils/approvalInbox.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
|
||||
import { canManageExpenseClaims } 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) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (canManageExpenseClaims(currentUser)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
node.includes('直属领导')
|
||||
|| node.includes('领导审批')
|
||||
|| node.includes('部门负责人')
|
||||
|| node.includes('负责人审批')
|
||||
)
|
||||
}
|
||||
|
||||
export function listPendingApprovalRequests(claimsPayload, currentUser) {
|
||||
if (!Array.isArray(claimsPayload)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return claimsPayload
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.filter((item) => item.approvalKey === 'in_progress')
|
||||
.filter((item) => canProcessApprovalRequest(item, currentUser))
|
||||
}
|
||||
|
||||
export function resolvePendingClaimIds(claimsPayload, currentUser) {
|
||||
return listPendingApprovalRequests(claimsPayload, currentUser)
|
||||
.map((item) => String(item.claimId || '').trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
188
web/src/utils/reimbursementTextInference.js
Normal file
188
web/src/utils/reimbursementTextInference.js
Normal file
@@ -0,0 +1,188 @@
|
||||
const DEFAULT_SESSION_TYPE_EXPENSE = 'expense'
|
||||
const DEFAULT_SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
|
||||
const DEFAULT_INTENT_LABELS = {
|
||||
query: '查询',
|
||||
explain: '解释',
|
||||
compare: '对比',
|
||||
risk_check: '风险检查',
|
||||
draft: '草稿生成',
|
||||
operate: '动作请求'
|
||||
}
|
||||
|
||||
const DEFAULT_SCENARIO_LABELS = {
|
||||
expense: '报销',
|
||||
accounts_receivable: '应收',
|
||||
accounts_payable: '应付',
|
||||
knowledge: '知识',
|
||||
unknown: '通用'
|
||||
}
|
||||
|
||||
const DEFAULT_EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
hotel: '住宿费',
|
||||
transport: '交通费',
|
||||
meal: '餐费',
|
||||
meeting: '会务费',
|
||||
entertainment: '业务招待费',
|
||||
office: '办公费',
|
||||
training: '培训费',
|
||||
communication: '通讯费',
|
||||
welfare: '福利费',
|
||||
other: '其他费用'
|
||||
}
|
||||
|
||||
export const TRANSPORT_KEYWORD_PATTERN = /交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费/
|
||||
|
||||
const FLOW_INTENT_KEYWORDS = {
|
||||
draft: ['报销', '报账', '草稿', '生成', '提交', '申请', '请走报销'],
|
||||
query: ['查询', '查一下', '多少', '明细', '统计'],
|
||||
risk_check: ['风险', '异常', '重复', '超标'],
|
||||
explain: ['为什么', '依据', '规则', '怎么']
|
||||
}
|
||||
|
||||
function normalizeCompactText(value) {
|
||||
return String(value || '').trim().replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function resolveExpenseTypeLabel(type, fallbackLabel = '', expenseTypeLabels = DEFAULT_EXPENSE_TYPE_LABELS) {
|
||||
const normalized = String(type || '').trim()
|
||||
return expenseTypeLabels[normalized] || String(fallbackLabel || '').trim() || expenseTypeLabels.other
|
||||
}
|
||||
|
||||
function resolveSemanticExpenseTypeLabel(semanticParse, expenseTypeLabels = DEFAULT_EXPENSE_TYPE_LABELS) {
|
||||
const entities = Array.isArray(semanticParse?.entities_json) ? semanticParse.entities_json : []
|
||||
const expenseTypeEntity = entities.find((item) => String(item?.type || '').trim() === 'expense_type')
|
||||
if (expenseTypeEntity) {
|
||||
return resolveExpenseTypeLabel(
|
||||
String(expenseTypeEntity.normalized_value || '').trim(),
|
||||
String(expenseTypeEntity.value || '').trim(),
|
||||
expenseTypeLabels
|
||||
)
|
||||
}
|
||||
|
||||
return resolveExpenseTypeLabel(
|
||||
String(semanticParse?.expense_type || semanticParse?.expense_type_code || '').trim(),
|
||||
String(semanticParse?.expense_type_label || '').trim(),
|
||||
expenseTypeLabels
|
||||
)
|
||||
}
|
||||
|
||||
export function inferLocalFlowCandidates(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = normalizeCompactText(text)
|
||||
|
||||
let time = ''
|
||||
const explicitTimeMatch = text.match(/发生时间[::]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
|
||||
if (explicitTimeMatch?.[1]) {
|
||||
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
|
||||
} else {
|
||||
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
|
||||
if (dateMatch?.[1]) {
|
||||
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
|
||||
} else if (/今天|今日/.test(compact)) {
|
||||
time = '今天'
|
||||
} else if (/昨天|昨日/.test(compact)) {
|
||||
time = '昨天'
|
||||
} else if (/前天/.test(compact)) {
|
||||
time = '前天'
|
||||
}
|
||||
}
|
||||
|
||||
let amount = ''
|
||||
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
|
||||
if (amountMatch?.[1]) {
|
||||
const numericValue = Number(amountMatch[1])
|
||||
if (Number.isFinite(numericValue)) {
|
||||
amount = Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元`
|
||||
}
|
||||
}
|
||||
|
||||
let event = ''
|
||||
let expenseType = ''
|
||||
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
|
||||
event = '请客户吃饭'
|
||||
expenseType = '业务招待费'
|
||||
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
|
||||
event = '出差行程'
|
||||
expenseType = '差旅费'
|
||||
} else if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
|
||||
event = '交通出行'
|
||||
expenseType = '交通费'
|
||||
} else if (/住宿|酒店|宾馆/.test(compact)) {
|
||||
event = '住宿报销'
|
||||
expenseType = '住宿费'
|
||||
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
|
||||
event = '餐饮用餐'
|
||||
expenseType = '餐费'
|
||||
}
|
||||
|
||||
return {
|
||||
time,
|
||||
amount,
|
||||
event,
|
||||
expenseType
|
||||
}
|
||||
}
|
||||
|
||||
export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_TYPE_EXPENSE, options = {}) {
|
||||
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
|
||||
return '初步识别为财务知识问答,正在准备检索范围'
|
||||
}
|
||||
|
||||
const compact = normalizeCompactText(rawText)
|
||||
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
|
||||
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
|
||||
keywords.some((keyword) => compact.includes(keyword))
|
||||
)?.[0] || 'draft'
|
||||
const intentLabel = intentLabels[intentKey] || DEFAULT_INTENT_LABELS[intentKey] || '处理'
|
||||
const candidates = inferLocalFlowCandidates(rawText)
|
||||
const expenseTypeText = candidates.expenseType ? `,费用类型为${candidates.expenseType}` : ''
|
||||
return `初步识别为报销场景,准备进入${intentLabel}${expenseTypeText}`
|
||||
}
|
||||
|
||||
export function buildLocalExtractionProgressMessages(rawText, options = {}) {
|
||||
const candidates = inferLocalFlowCandidates(rawText)
|
||||
const messages = []
|
||||
|
||||
messages.push('正在提取发生时间...')
|
||||
messages.push(
|
||||
candidates.time
|
||||
? `发现发生时间 ${candidates.time},继续提取金额...`
|
||||
: '暂未定位到明确时间,继续提取金额...'
|
||||
)
|
||||
messages.push(
|
||||
candidates.amount
|
||||
? `发现金额 ${candidates.amount},继续识别事件类型...`
|
||||
: '暂未定位到明确金额,继续识别事件类型...'
|
||||
)
|
||||
|
||||
if (candidates.event || candidates.expenseType) {
|
||||
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
|
||||
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
|
||||
} else {
|
||||
messages.push('正在识别事件类型和费用分类...')
|
||||
}
|
||||
|
||||
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
|
||||
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export function summarizeSemanticIntentDetail(semanticParse, options = {}) {
|
||||
if (!semanticParse || typeof semanticParse !== 'object') {
|
||||
return options.fallbackText || '意图识别完成'
|
||||
}
|
||||
|
||||
const scenarioLabels = options.scenarioLabels || DEFAULT_SCENARIO_LABELS
|
||||
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
|
||||
const expenseTypeLabels = options.expenseTypeLabels || DEFAULT_EXPENSE_TYPE_LABELS
|
||||
const scenarioLabel = scenarioLabels[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
|
||||
const intentLabel = intentLabels[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
|
||||
const expenseTypeLabel = resolveSemanticExpenseTypeLabel(semanticParse, expenseTypeLabels)
|
||||
const expenseTypeText = expenseTypeLabel && expenseTypeLabel !== expenseTypeLabels.other
|
||||
? `,费用类型为${expenseTypeLabel}`
|
||||
: ''
|
||||
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}${expenseTypeText}`
|
||||
}
|
||||
@@ -194,7 +194,13 @@ export function normalizeRequestForUi(request) {
|
||||
const sceneTarget = String(request.sceneTarget || request.location || request.city || request.entity || '').trim() || '待补充'
|
||||
const occurredDisplay = String(request.occurredDisplay || request.period || request.occurredAt || '').trim() || '待补充'
|
||||
const applyTime = String(request.applyTime || parseRequestDateFromId(request.id) || '').trim() || '待补充'
|
||||
const workflowNode = String(request.workflowNode || request.node || '').trim() || '待提交'
|
||||
const workflowNode = String(
|
||||
request.workflowNode
|
||||
|| request.node
|
||||
|| request.approval_stage
|
||||
|| request.approvalStage
|
||||
|| ''
|
||||
).trim() || '待提交'
|
||||
const secondaryStatusValue =
|
||||
String(request.secondaryStatusValue || request.travel || '').trim()
|
||||
|| (detailVariant === 'travel' ? '待安排行程' : '待补充票据')
|
||||
|
||||
22
web/src/utils/workbenchIconAssets.js
Normal file
22
web/src/utils/workbenchIconAssets.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import briefcaseIcon from '../assets/workbench-icons/outline-briefcase.svg?raw'
|
||||
import documentTextIcon from '../assets/workbench-icons/outline-document-text.svg?raw'
|
||||
import paperAirplaneIcon from '../assets/workbench-icons/outline-paper-airplane.svg?raw'
|
||||
import shoppingBagIcon from '../assets/workbench-icons/outline-shopping-bag.svg?raw'
|
||||
import truckIcon from '../assets/workbench-icons/outline-truck.svg?raw'
|
||||
import usersIcon from '../assets/workbench-icons/outline-users.svg?raw'
|
||||
|
||||
function prepareHeroiconMarkup(svgRaw) {
|
||||
return String(svgRaw || '')
|
||||
.replace(/<svg\b([^>]*)>/i, '<svg class="workbench-heroicon"$1>')
|
||||
.replace(/\sdata-slot="[^"]*"/g, '')
|
||||
.replace(/\saria-hidden="[^"]*"/g, '')
|
||||
}
|
||||
|
||||
export const workbenchIconMap = {
|
||||
hospitality: { markup: prepareHeroiconMarkup(usersIcon), style: 'outline' },
|
||||
travelDraft: { markup: prepareHeroiconMarkup(briefcaseIcon), style: 'outline' },
|
||||
receipts: { markup: prepareHeroiconMarkup(documentTextIcon), style: 'outline' },
|
||||
flight: { markup: prepareHeroiconMarkup(paperAirplaneIcon), style: 'outline' },
|
||||
transport: { markup: prepareHeroiconMarkup(truckIcon), style: 'outline' },
|
||||
procurement: { markup: prepareHeroiconMarkup(shoppingBagIcon), style: 'outline' }
|
||||
}
|
||||
82
web/src/utils/workbenchSummary.js
Normal file
82
web/src/utils/workbenchSummary.js
Normal file
@@ -0,0 +1,82 @@
|
||||
function parseNumber(value) {
|
||||
const nextValue = Number(value)
|
||||
return Number.isFinite(nextValue) ? nextValue : 0
|
||||
}
|
||||
|
||||
function toDate(value) {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nextDate = new Date(value)
|
||||
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
||||
}
|
||||
|
||||
function isCurrentMonth(dateValue) {
|
||||
const date = toDate(dateValue)
|
||||
if (!date) {
|
||||
return false
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth()
|
||||
}
|
||||
|
||||
function resolveClaimDate(request) {
|
||||
return request?.submittedAt || request?.createdAt || request?.occurredAt || ''
|
||||
}
|
||||
|
||||
export function belongsToCurrentUser(request, currentUser) {
|
||||
const person = String(request?.person || request?.employeeName || '').trim()
|
||||
if (!person) {
|
||||
return false
|
||||
}
|
||||
|
||||
const names = [
|
||||
String(currentUser?.name || '').trim(),
|
||||
String(currentUser?.username || '').trim()
|
||||
].filter(Boolean)
|
||||
|
||||
return names.some((name) => name === person)
|
||||
}
|
||||
|
||||
export function hasHighRiskFlag(request) {
|
||||
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
|
||||
|
||||
if (riskFlags.some((item) => String(item?.severity || '').trim().toLowerCase() === 'high')) {
|
||||
return true
|
||||
}
|
||||
|
||||
const summary = String(request?.riskSummary || '').trim()
|
||||
return summary.includes('高')
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||||
}).format(parseNumber(value))
|
||||
}
|
||||
|
||||
export function buildWorkbenchSummary(requests, currentUser) {
|
||||
const ownedRequests = Array.isArray(requests)
|
||||
? requests.filter((item) => belongsToCurrentUser(item, currentUser))
|
||||
: []
|
||||
|
||||
const monthlyClaims = ownedRequests.filter((item) => isCurrentMonth(resolveClaimDate(item)))
|
||||
|
||||
const monthlyCount = monthlyClaims.length
|
||||
const monthlyAmount = monthlyClaims.reduce((sum, item) => sum + parseNumber(item.amount), 0)
|
||||
const returnCount = ownedRequests.filter((item) => item.approvalKey === 'rejected').length
|
||||
const highRiskCount = monthlyClaims.filter((item) => hasHighRiskFlag(item)).length
|
||||
|
||||
return {
|
||||
monthlyCount,
|
||||
monthlyAmount,
|
||||
monthlyAmountLabel: formatCurrency(monthlyAmount),
|
||||
returnCount,
|
||||
highRiskCount
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user