799 lines
27 KiB
JavaScript
799 lines
27 KiB
JavaScript
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
|
||
})
|
||
}
|