Files
X-Financial/web/src/views/scripts/travelRequestDetailInsights.js
2026-06-03 17:31:40 +08:00

799 lines
27 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.
import {
isActionableRiskFlag,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import {
resolveRiskActionability,
resolveRiskDomain,
resolveRiskVisibilityScope
} from '../../utils/riskVisibility.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
ship_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 normalizeBusinessStage(value) {
const stage = normalizeText(value).toLowerCase()
if ([
'expense_application',
'application',
'apply',
'pre_apply',
'pre_application',
'budget_application'
].includes(stage)) {
return 'expense_application'
}
if ([
'reimbursement',
'expense_reimbursement',
'claim',
'expense_claim',
'expense_report'
].includes(stage)) {
return 'reimbursement'
}
return ''
}
function resolveFlagBusinessStage(flag, fallback = 'reimbursement') {
if (!flag || typeof flag !== 'object') {
return resolveRiskTextBusinessStage(flag, fallback)
}
const explicitStage = normalizeBusinessStage(
flag.businessStage
|| flag.business_stage
|| flag.controlStage
|| flag.control_stage
)
if (explicitStage) {
return explicitStage
}
const source = normalizeText(flag.source).toLowerCase()
const eventType = normalizeText(flag.event_type || flag.eventType).toLowerCase()
if (source === 'attachment_analysis' || /expense_claim|reimbursement|payment/.test(eventType)) {
return 'reimbursement'
}
if (/application/.test(source) || /expense_application/.test(eventType)) {
return 'expense_application'
}
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
}
function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
const text = normalizeText(value)
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
return 'reimbursement'
}
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
return 'expense_application'
}
return fallback
}
function cardLikeText(card = {}) {
return [
card.label,
card.title,
card.risk,
card.message,
card.summary,
card.suggestion,
card.description,
card.detail
].map((item) => normalizeText(item)).join(' ')
}
function resolveRequestBusinessStage(request = {}) {
const explicitStage = normalizeBusinessStage(
request?.businessStage
|| request?.business_stage
|| request?.controlStage
|| request?.control_stage
)
if (explicitStage) {
return explicitStage
}
const documentType = normalizeText(
request?.documentTypeCode
|| request?.document_type_code
|| request?.documentType
|| request?.document_type
).toLowerCase()
if (['application', 'expense_application'].includes(documentType)) {
return 'expense_application'
}
const claimNo = normalizeText(
request?.claimNo
|| request?.claim_no
|| request?.documentNo
|| request?.document_no
|| request?.id
).toUpperCase()
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) {
return 'expense_application'
}
const typeCode = normalizeText(request?.typeCode || request?.expense_type).toLowerCase()
if (typeCode === 'application' || typeCode.endsWith('_application')) {
return 'expense_application'
}
return 'reimbursement'
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
if (tone === 'low') return 'low'
return 'medium'
}
function resolveRiskLevelLabel(tone) {
const normalizedTone = normalizeTone(tone)
if (normalizedTone === 'high') return '高风险'
if (normalizedTone === 'medium') return '中风险'
if (normalizedTone === 'low') return '低风险'
return '风险提示'
}
export function normalizeRiskTone(value) {
return normalizeTone(value)
}
function resolveFlagTone(flag) {
return normalizeRiskFlagTone(flag)
}
function isRiskTone(tone) {
return ['medium', 'high'].includes(normalizeText(tone).toLowerCase())
}
function normalizeId(value) {
return normalizeText(value)
}
function resolveItemRiskFlag(item, claimRiskFlags) {
const itemId = normalizeId(item?.id)
if (!itemId || !Array.isArray(claimRiskFlags)) {
return null
}
return claimRiskFlags.find((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
if (!isActionableRiskFlag(flag)) {
return false
}
const flagItemId = normalizeId(flag.item_id || flag.itemId)
const tone = resolveFlagTone(flag)
return flagItemId === itemId && isRiskTone(tone)
}) || null
}
export function buildItemClaimRiskState(item, claimRiskFlags = []) {
const flag = resolveItemRiskFlag(item, claimRiskFlags)
if (!flag) {
return null
}
const tone = resolveFlagTone(flag)
const label = normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险')
const points = Array.isArray(flag.points)
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
: []
const summary = normalizeText(flag.summary || flag.message || flag.reason)
return {
label,
tone,
headline: normalizeText(flag.headline || flag.title) || label,
summary,
points,
suggestion: normalizeText(flag.suggestion) || '如业务确需提交,请在附加说明中补充特殊情况原因后继续提交。'
}
}
export function resolveRiskTagTone(tag) {
const normalized = normalizeText(tag).toLowerCase()
if (normalized === '#high_risk') return 'high'
if (normalized === '#middle_risk') return 'medium'
if (normalized === '#low_risk') return 'low'
if (normalized === '#hotel') return 'hotel'
if (normalized === '#traffic') return 'traffic'
return 'neutral'
}
export function extractRiskTagsFromText(text) {
const matches = normalizeText(text).match(/#[A-Za-z_]+/g) || []
return [...new Set(matches.map((tag) => tag.toLowerCase()))]
}
export function resolveRiskTags(card = {}) {
const tags = []
const tone = normalizeTone(card.tone || card.severity)
if (tone === 'high') {
tags.push('#high_risk')
} else if (tone === 'medium') {
tags.push('#middle_risk')
} else if (tone === 'low') {
tags.push('#low_risk')
}
const text = [
card.label,
card.title,
card.risk,
card.summary,
card.suggestion,
card.itemType,
card.documentType
].map((item) => normalizeText(item).toLowerCase()).join(' ')
if (/住宿|酒店|宾馆|hotel/.test(text)) {
tags.push('#hotel')
}
if (/交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi|traffic|transport/.test(text)) {
tags.push('#traffic')
}
return [...new Set(tags)]
}
function withRiskTags(card) {
const businessStage = normalizeBusinessStage(
card.businessStage
|| card.business_stage
|| card.controlStage
|| card.control_stage
)
const riskDomain = resolveRiskDomain(card)
const actionability = resolveRiskActionability(card, { businessStage, riskDomain })
const visibilityScope = resolveRiskVisibilityScope(card, { businessStage, riskDomain, actionability })
return {
...card,
...(businessStage ? { businessStage } : {}),
riskDomain,
risk_domain: riskDomain,
actionability,
visibilityScope,
visibility_scope: visibilityScope,
tags: resolveRiskTags(card)
}
}
export function filterRiskCardsByBusinessStage(cards = [], businessStage = 'reimbursement') {
const targetStage = normalizeBusinessStage(businessStage) || 'reimbursement'
return (Array.isArray(cards) ? cards : []).filter(
(card) => resolveFlagBusinessStage(card, targetStage) === targetStage
)
}
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] : []
}
function resolveClaimRiskRuleBasis(flag = {}, { risk = '', summary = '', tone = 'medium' } = {}) {
const explicitBasis = normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
if (explicitBasis.length) {
return explicitBasis
}
const source = normalizeText(flag.source)
const label = normalizeText(flag.label || flag.title || flag.name)
const corpus = [risk, summary, label].map((item) => normalizeText(item)).join(' ')
const basis = []
if (/高风险|中风险/.test(corpus)) {
basis.push(`风险文本已明确标记为${tone === 'high' ? '高风险' : '中风险'}`)
}
if (source === 'attachment_analysis' || /附件|票据|OCR|识别|发票/.test(corpus)) {
basis.push('附件识别或票据核验未完全通过,系统将该项同步为单据风险。')
}
if (/直属领导|审批链|审批人缺失|审批人信息|补充分配|分配/.test(corpus)) {
basis.push('审批链校验未匹配到完整审批人信息,因此按中风险提醒。')
}
if (/金额|超标|阈值|住宿标准|报销标准|标准/.test(corpus)) {
basis.push('金额或标准核算命中制度阈值,需要补充说明或人工复核。')
}
if (/历史|近\s*\d+\s*天|重复|多次/.test(corpus)) {
basis.push('历史报销风险次数达到预警条件,系统提示审批人重点关注。')
}
if (/缺失|缺少|未识别|待补充|不一致|不匹配/.test(corpus)) {
basis.push('单据、附件或审批信息存在缺失、不一致或待补充项。')
}
if (summary) {
basis.push(`风险汇总:${summary}`)
}
return uniqueTexts(basis.length ? basis : [`系统预审根据“${label || '单据风险'}”将该项列为${tone === 'high' ? '高风险' : '中风险'}`])
}
function resolveClaimRiskSuggestion(flag = {}, { risk = '', summary = '' } = {}) {
const explicitSuggestion = normalizeText(flag.suggestion)
if (explicitSuggestion) {
return explicitSuggestion
}
const corpus = [risk, summary, flag.label, flag.title].map((item) => normalizeText(item)).join(' ')
if (/直属领导|审批链|审批人缺失|审批人信息|补充分配|分配/.test(corpus)) {
return '请先核对员工档案中的直属领导和审批链配置;如果信息无误,可由审批环节人工补充分配说明。'
}
if (/金额|超标|阈值|住宿标准|报销标准|标准/.test(corpus)) {
return '请核对金额、天数、地点和职级标准;如确需超标,请在附加说明中写清楚业务原因和佐证材料。'
}
if (/附件|票据|OCR|识别|发票/.test(corpus)) {
return '请打开对应费用明细的附件预览,核对票据类型、金额、日期和说明;识别有误时先修正明细或重新上传附件。'
}
if (/历史|近\s*\d+\s*天|重复|多次/.test(corpus)) {
return '请核对近期同类报销记录,必要时补充本次费用与历史单据不同的业务背景。'
}
if (/缺失|缺少|未识别|待补充|不一致|不匹配/.test(corpus)) {
return '请按风险点补齐缺失信息,并核对费用明细与附件内容是否一致。'
}
return '请先核对上方触发原因;如属于真实业务例外,在附加说明中写清楚原因和佐证后再继续流转。'
}
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, businessStage = 'reimbursement' }) {
const tone = normalizeTone(analysis?.severity)
const title = normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name) || '附件风险'
return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
itemId: normalizeId(item?.id),
itemIndex: index + 1,
invoiceId: normalizeText(item?.invoiceId),
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
tone,
label: resolveRiskLevelLabel(tone),
title: `${index + 1} 条:${title}`,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight),
source: 'attachment_analysis',
itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel),
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
})
}
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, businessStage = 'reimbursement') {
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 withRiskTags({
id: `manual-return-${returnCount || 'latest'}`,
businessStage: resolveFlagBusinessStage(flag, normalizeBusinessStage(businessStage) || 'reimbursement'),
tone: 'medium',
label: '退回原因',
title: returnCount ? `${returnCount} 次退回` : '审批退回',
risk,
summary: normalizeText(flag.reason),
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。',
risk_domain: flag.risk_domain || flag.riskDomain || 'workflow',
visibility_scope: flag.visibility_scope || flag.visibilityScope || 'submitter',
actionability: flag.actionability || 'fixable_by_submitter'
})
}
export function buildAttachmentRiskCards({
expenseItems = [],
attachmentMetaByItemId = {},
claimRiskFlags = [],
businessStage = 'reimbursement'
} = {}) {
const normalizedBusinessStage = normalizeBusinessStage(businessStage) || 'reimbursement'
const attachmentRiskItemIds = new Set()
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 itemId = normalizeId(item.id)
if (itemId) {
attachmentRiskItemIds.add(itemId)
}
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,
businessStage: normalizedBusinessStage
}))
.filter((card) => card.risk)
})
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
const latestManualReturnCard = buildManualReturnRiskCard(
resolveLatestManualReturnFlag(normalizedClaimRiskFlags),
normalizedBusinessStage
)
const claimCards = normalizedClaimRiskFlags
.flatMap((flag, index) => {
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
return []
}
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'ai_pre_review') {
return []
}
if (!flag || typeof flag !== 'object') {
if (!isActionableRiskFlag(flag)) {
return []
}
const risk = normalizeText(flag)
return risk
? [withRiskTags({
id: `claim-risk-${index}`,
businessStage: resolveRiskTextBusinessStage(risk, normalizedBusinessStage),
tone: 'medium',
label: '单据风险',
title: '单据风险提示',
risk,
summary: '',
ruleBasis: resolveClaimRiskRuleBasis({}, { risk, tone: 'medium' }),
suggestion: resolveClaimRiskSuggestion({}, { risk })
})]
: []
}
if (!isActionableRiskFlag(flag)) {
return []
}
const source = normalizeText(flag.source)
const flagItemId = normalizeId(flag.item_id || flag.itemId)
if (source === 'attachment_analysis' && flagItemId && attachmentRiskItemIds.has(flagItemId)) {
return []
}
const tone = resolveFlagTone(flag)
if (!isRiskTone(tone)) {
return []
}
const flagPoints = Array.isArray(flag.points)
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
: []
const primaryRisk = normalizeText(flag.message || flag.reason || flag.summary)
const fallbackRisk = normalizeText(flag.description || flag.detail || flag.title || flag.label || flag.name)
const risks = flagPoints.length
? flagPoints
: [primaryRisk || fallbackRisk].filter(Boolean)
const summary = normalizeText(flag.summary || flag.message || flag.reason)
const ruleBasis = resolveClaimRiskRuleBasis(flag, {
risk: risks[0] || primaryRisk || fallbackRisk,
summary,
tone
})
return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`,
itemId: flagItemId,
itemIndex: Number(flag.item_index ?? flag.itemIndex ?? 0) || null,
invoiceId: normalizeText(flag.invoice_id || flag.invoiceId),
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
tone,
label: resolveRiskLevelLabel(tone),
title: normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
risk,
summary,
ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
source,
risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope,
actionability: flag.actionability
}))
})
.filter(Boolean)
if (latestManualReturnCard) {
claimCards.unshift(latestManualReturnCard)
}
return [...attachmentCards, ...claimCards]
}
function isNoRiskSummary(value) {
return !isRiskSummaryWithRisk(value)
}
function resolveClaimSummaryTone(request) {
const explicitTone = normalizeText(request?.riskTone || request?.risk_tone || request?.severity)
if (explicitTone) {
return normalizeTone(explicitTone)
}
const summary = normalizeText(request?.riskSummary || request?.risk || request?.riskText)
if (/高风险|重大风险|严重|超标|违规/.test(summary)) {
return 'high'
}
if (/中风险|待关注|待复核|提醒|异常|风险/.test(summary)) {
return 'medium'
}
return 'medium'
}
export function buildClaimSummaryRiskCards(request = {}) {
const summary = normalizeText(request?.riskSummary || request?.risk || request?.riskText)
if (isNoRiskSummary(summary)) {
return []
}
const tone = resolveClaimSummaryTone(request)
if (!isRiskTone(tone)) {
return []
}
const businessStage = resolveRiskTextBusinessStage(summary, resolveRequestBusinessStage(request))
return [withRiskTags({
id: 'claim-risk-summary',
businessStage,
tone,
label: resolveRiskLevelLabel(tone),
title: '单据风险提示',
risk: summary,
summary,
ruleBasis: resolveClaimRiskRuleBasis(
{ source: 'risk_summary', message: summary, severity: tone },
{ risk: summary, summary, tone }
),
suggestion: resolveClaimRiskSuggestion({ source: 'risk_summary' }, { risk: summary, summary })
})]
}
export function buildAiAdviceViewModel({
completionItems = [],
materialPrompts = [],
profileAdviceItems = [],
riskCards = []
} = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean)
const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
if (
!normalizedCompletionItems.length
&& !normalizedMaterialPrompts.length
&& !normalizedProfileAdviceItems.length
&& !normalizedRiskCards.length
) {
return {
tone: 'ready',
badge: '可以提交',
summary: '自动检测未发现票据、金额、行程或历史画像异常,可以提交审批。',
items: [],
riskCards: [],
sections: []
}
}
const sections = []
if (normalizedCompletionItems.length) {
sections.push({
kind: 'completion',
title: '建议补充字段',
items: normalizedCompletionItems
})
}
if (normalizedMaterialPrompts.length) {
sections.push({
kind: 'material',
title: '材料补充提示',
items: normalizedMaterialPrompts
})
}
if (normalizedProfileAdviceItems.length) {
sections.push({
kind: 'profile',
title: '历史操作建议',
items: normalizedProfileAdviceItems
})
}
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
title: `已知存在风险(${normalizedRiskCards.length}项)`,
items: sortedRiskCards,
totalCount: normalizedRiskCards.length
})
}
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : normalizedRiskCards.length ? '待核对' : '建议关注',
summary: normalizedRiskCards.length
? `自动检测发现 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
: normalizedMaterialPrompts.length
? `自动检测发现 ${normalizedMaterialPrompts.length} 条材料补充提示,不作为风险计数。`
: '结合历史操作记录生成提交建议,请按提示核对后提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards,
sections
}
}
function sortRiskCardsByTone(cards) {
const toneWeight = {
high: 0,
medium: 1,
low: 2,
normal: 3,
pass: 4
}
return [...cards].sort((left, right) => {
const leftWeight = toneWeight[normalizeText(left?.tone).toLowerCase()] ?? 9
const rightWeight = toneWeight[normalizeText(right?.tone).toLowerCase()] ?? 9
return leftWeight - rightWeight
})
}