Files
X-Financial/web/src/views/scripts/travelRequestDetailInsights.js
caoxiaozhu e701fa01da feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
2026-05-21 16:09:47 +08:00

322 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
.flatMap((flag, index) => {
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
return []
}
if (!flag || typeof flag !== 'object') {
const risk = normalizeText(flag)
return risk
? [{
id: `claim-risk-${index}`,
tone: 'medium',
label: '单据风险',
title: '单据风险提示',
risk,
summary: '',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
}]
: []
}
const tone = normalizeTone(flag.severity)
if (!['medium', 'high'].includes(tone)) {
return []
}
const flagPoints = Array.isArray(flag.points)
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
: []
const risks = flagPoints.length
? flagPoints
: [normalizeText(flag.message || flag.reason || flag.summary)].filter(Boolean)
const summary = normalizeText(flag.summary)
const ruleBasis = uniqueTexts([
...normalizeRuleBasis(flag.rule_basis || flag.ruleBasis),
summary ? `风险汇总:${summary}` : '',
'系统预审规则命中该风险提示。'
])
return risks.map((risk, pointIndex) => ({
id: `claim-risk-${index}-${pointIndex}`,
tone,
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
title: normalizeText(flag.label) || '单据风险提示',
risk,
summary,
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) {
const items = [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
]
return {
tone: 'ready',
badge: '可直接提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items,
riskCards: [],
sections: []
}
}
const sections = []
if (normalizedCompletionItems.length) {
sections.push({
kind: 'completion',
title: '建议补充字段',
items: normalizedCompletionItems
})
}
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
title: '已知存在风险',
items: normalizedRiskCards
})
}
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对',
summary: normalizedRiskCards.length
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
: '建议先补齐必填信息,完成后即可提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards,
sections
}
}