import { isActionableRiskFlag, isRiskSummaryWithRisk, normalizeRiskFlagTone } from '../../utils/riskFlags.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 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' } 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) { return { ...card, tags: resolveRiskTags(card) } } 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 }) { const tone = normalizeTone(analysis?.severity) const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险') return withRiskTags({ 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), itemType: normalizeText(item?.itemType), documentType: normalizeText(insight?.documentTypeLabel) }) } 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 withRiskTags({ 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 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 })) .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') { if (!isActionableRiskFlag(flag)) { return [] } const risk = normalizeText(flag) return risk ? [withRiskTags({ id: `claim-risk-${index}`, 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}`, tone, label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'), title: normalizeText(flag.label) || '单据风险提示', risk, summary, ruleBasis, suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }) })) }) .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 [] } return [withRiskTags({ id: 'claim-risk-summary', tone, label: tone === 'high' ? '高风险' : '中风险', 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 = [], 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 } }