feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
290
web/src/views/scripts/travelRequestDetailInsights.js
Normal file
290
web/src/views/scripts/travelRequestDetailInsights.js
Normal file
@@ -0,0 +1,290 @@
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
meal_receipt: '餐饮票据',
|
||||
office_invoice: '办公用品票据',
|
||||
meeting_invoice: '会议/会务票据',
|
||||
training_invoice: '培训票据',
|
||||
vat_invoice: '增值税发票',
|
||||
receipt: '一般收据/凭证',
|
||||
other: '其他单据'
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function uniqueTexts(values) {
|
||||
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
|
||||
}
|
||||
|
||||
function normalizeTone(value) {
|
||||
const tone = normalizeText(value).toLowerCase()
|
||||
if (tone === 'pass') return 'pass'
|
||||
if (tone === 'high') return 'high'
|
||||
if (tone === 'medium') return 'medium'
|
||||
if (tone === 'low') return 'low'
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
function normalizeRuleBasis(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeText(item)).filter(Boolean)
|
||||
}
|
||||
|
||||
const text = normalizeText(value)
|
||||
return text ? [text] : []
|
||||
}
|
||||
|
||||
export function buildAttachmentInsightViewModel(metadata, item = {}) {
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
|
||||
const documentInfo = metadata.document_info || {}
|
||||
const requirementCheck = metadata.requirement_check || null
|
||||
const analysis = metadata.analysis || null
|
||||
const documentTypeLabel =
|
||||
normalizeText(documentInfo.document_type_label) || resolveDocumentTypeLabel(documentInfo.document_type)
|
||||
const fields = Array.isArray(documentInfo.fields)
|
||||
? documentInfo.fields
|
||||
.map((field) => ({
|
||||
label: normalizeText(field?.label),
|
||||
value: normalizeText(field?.value)
|
||||
}))
|
||||
.filter((field) => field.label && field.value)
|
||||
.map((field) => `${field.label}:${field.value}`)
|
||||
: []
|
||||
const ruleBasis = uniqueTexts([
|
||||
...normalizeRuleBasis(analysis?.rule_basis || analysis?.ruleBasis),
|
||||
...normalizeRuleBasis(requirementCheck?.rule_basis || requirementCheck?.ruleBasis),
|
||||
normalizeText(requirementCheck?.message),
|
||||
documentTypeLabel ? `票据识别依据:系统将附件识别为${documentTypeLabel}。` : '',
|
||||
normalizeText(item?.name) ? `费用项目依据:当前明细为${normalizeText(item.name)}。` : ''
|
||||
])
|
||||
|
||||
return {
|
||||
fileName: normalizeText(metadata.file_name || item.attachmentHint || item.invoiceId),
|
||||
mediaType: normalizeText(metadata.media_type),
|
||||
previewable: metadata.previewable !== false,
|
||||
documentTypeLabel,
|
||||
requirementLabel: requirementCheck
|
||||
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
|
||||
: '待校验附件类型',
|
||||
requirementTone: requirementCheck
|
||||
? (requirementCheck.matches ? 'pass' : 'high')
|
||||
: 'medium',
|
||||
message: normalizeText(requirementCheck?.message),
|
||||
fields: fields.slice(0, 8),
|
||||
ruleBasis,
|
||||
analysis: analysis
|
||||
? {
|
||||
label: normalizeText(analysis.label) || 'AI提示',
|
||||
tone: normalizeTone(analysis.severity),
|
||||
headline: normalizeText(analysis.headline) || normalizeText(analysis.label) || 'AI提示',
|
||||
summary: normalizeText(analysis.summary),
|
||||
points: Array.isArray(analysis.points) ? analysis.points.map((point) => normalizeText(point)).filter(Boolean) : [],
|
||||
suggestion: normalizeText(analysis.suggestion)
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function buildCardSuggestion(analysis, insight) {
|
||||
return (
|
||||
normalizeText(analysis?.suggestion)
|
||||
|| normalizeText(insight?.message)
|
||||
|| '请根据规则依据核对附件和费用明细,必要时补充说明、更换附件或调整费用项目。'
|
||||
)
|
||||
}
|
||||
|
||||
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险')
|
||||
|
||||
return {
|
||||
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
|
||||
tone,
|
||||
label,
|
||||
title: `第 ${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
|
||||
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
|
||||
summary: normalizeText(analysis?.summary),
|
||||
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
|
||||
suggestion: buildCardSuggestion(analysis, insight)
|
||||
}
|
||||
}
|
||||
|
||||
function parseReturnCount(flag) {
|
||||
const count = Number(flag?.return_count ?? flag?.returnCount ?? 0)
|
||||
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0
|
||||
}
|
||||
|
||||
function resolveLatestManualReturnFlag(flags) {
|
||||
const manualReturnFlags = flags.filter(
|
||||
(flag) => flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return'
|
||||
)
|
||||
if (!manualReturnFlags.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return manualReturnFlags.reduce((latest, flag) => {
|
||||
const latestCount = parseReturnCount(latest)
|
||||
const nextCount = parseReturnCount(flag)
|
||||
if (nextCount !== latestCount) {
|
||||
return nextCount > latestCount ? flag : latest
|
||||
}
|
||||
|
||||
const latestTime = Date.parse(normalizeText(latest?.created_at || latest?.createdAt))
|
||||
const nextTime = Date.parse(normalizeText(flag?.created_at || flag?.createdAt))
|
||||
if (Number.isFinite(nextTime) && (!Number.isFinite(latestTime) || nextTime >= latestTime)) {
|
||||
return flag
|
||||
}
|
||||
|
||||
return latest
|
||||
}, manualReturnFlags[0])
|
||||
}
|
||||
|
||||
function buildManualReturnRiskCard(flag) {
|
||||
if (!flag) {
|
||||
return null
|
||||
}
|
||||
|
||||
const returnCount = parseReturnCount(flag)
|
||||
const stageReturnCount = Number(flag.stage_return_count ?? flag.stageReturnCount ?? 0)
|
||||
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
|
||||
const riskPoints = Array.isArray(flag.risk_points || flag.riskPoints)
|
||||
? (flag.risk_points || flag.riskPoints).map((item) => normalizeText(item)).filter(Boolean)
|
||||
: []
|
||||
const risk = normalizeText(flag.message || flag.reason || flag.summary) || '审批人退回该单据,请补充后重新提交。'
|
||||
const ruleBasis = uniqueTexts([
|
||||
returnCount ? `累计退回 ${returnCount} 次。` : '',
|
||||
returnStage ? `本次退回环节:${returnStage}。` : '',
|
||||
stageReturnCount > 0 ? `该环节累计退回 ${Math.floor(stageReturnCount)} 次。` : '',
|
||||
...riskPoints.map((item) => `退回风险点:${item}。`)
|
||||
])
|
||||
|
||||
return {
|
||||
id: `manual-return-${returnCount || 'latest'}`,
|
||||
tone: 'medium',
|
||||
label: '退回原因',
|
||||
title: returnCount ? `第 ${returnCount} 次退回` : '审批退回',
|
||||
risk,
|
||||
summary: normalizeText(flag.reason),
|
||||
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
|
||||
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAttachmentRiskCards({
|
||||
expenseItems = [],
|
||||
attachmentMetaByItemId = {},
|
||||
claimRiskFlags = []
|
||||
} = {}) {
|
||||
const attachmentCards = expenseItems.flatMap((item, index) => {
|
||||
if (!item?.invoiceId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const metadata = attachmentMetaByItemId[item.id]
|
||||
const insight = buildAttachmentInsightViewModel(metadata, item)
|
||||
const analysis = metadata?.analysis
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
if (!analysis || !['medium', 'high'].includes(tone)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const points = Array.isArray(analysis.points) && analysis.points.length
|
||||
? analysis.points
|
||||
: [analysis.summary || analysis.headline || analysis.label]
|
||||
|
||||
return points
|
||||
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
|
||||
.filter((card) => card.risk)
|
||||
})
|
||||
|
||||
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
|
||||
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
|
||||
const claimCards = normalizedClaimRiskFlags
|
||||
.map((flag, index) => {
|
||||
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
const risk = normalizeText(flag)
|
||||
return risk
|
||||
? {
|
||||
id: `claim-risk-${index}`,
|
||||
tone: 'medium',
|
||||
label: '单据风险',
|
||||
title: '单据风险提示',
|
||||
risk,
|
||||
summary: '',
|
||||
ruleBasis: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const tone = normalizeTone(flag.severity)
|
||||
if (!['medium', 'high'].includes(tone)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: `claim-risk-${index}`,
|
||||
tone,
|
||||
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
|
||||
title: normalizeText(flag.label) || '单据风险提示',
|
||||
risk: normalizeText(flag.message || flag.reason || flag.summary),
|
||||
summary: normalizeText(flag.summary),
|
||||
ruleBasis: normalizeRuleBasis(flag.rule_basis || flag.ruleBasis).length
|
||||
? normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
|
||||
: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (latestManualReturnCard) {
|
||||
claimCards.unshift(latestManualReturnCard)
|
||||
}
|
||||
|
||||
return [...attachmentCards, ...claimCards]
|
||||
}
|
||||
|
||||
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
|
||||
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
|
||||
const normalizedRiskCards = riskCards.filter(Boolean)
|
||||
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
|
||||
|
||||
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items: [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
],
|
||||
riskCards: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待核对',
|
||||
summary: normalizedRiskCards.length
|
||||
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
|
||||
: '建议先补齐必填信息,完成后即可提交审批。',
|
||||
items: normalizedCompletionItems,
|
||||
riskCards: normalizedRiskCards
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user