feat: 新增归档中心页面并完善知识库与报销查询能力

新增前端归档中心视图及相关工具函数,扩充知识库文档分类和
提取器支持多种格式,增强编排器报销查询的多维度检索,优
化本体规则和用户代理审核消息,前端完善报销创建和审批详
情交互细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-22 16:00:19 +08:00
parent 1f15699013
commit 88ff04bef8
120 changed files with 6236 additions and 643 deletions

View File

@@ -1,3 +1,9 @@
import {
isActionableRiskFlag,
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
train_ticket: '火车/高铁票',
@@ -34,6 +40,62 @@ 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'
@@ -99,6 +161,68 @@ function normalizeRuleBasis(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
@@ -245,6 +369,7 @@ export function buildAttachmentRiskCards({
attachmentMetaByItemId = {},
claimRiskFlags = []
} = {}) {
const attachmentRiskItemIds = new Set()
const attachmentCards = expenseItems.flatMap((item, index) => {
if (!item?.invoiceId) {
return []
@@ -257,6 +382,10 @@ export function buildAttachmentRiskCards({
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
@@ -276,6 +405,10 @@ export function buildAttachmentRiskCards({
}
if (!flag || typeof flag !== 'object') {
if (!isActionableRiskFlag(flag)) {
return []
}
const risk = normalizeText(flag)
return risk
? [withRiskTags({
@@ -285,29 +418,41 @@ export function buildAttachmentRiskCards({
title: '单据风险提示',
risk,
summary: '',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
ruleBasis: resolveClaimRiskRuleBasis({}, { risk, tone: 'medium' }),
suggestion: resolveClaimRiskSuggestion({}, { risk })
})]
: []
}
const tone = normalizeTone(flag.severity)
if (!['medium', 'high'].includes(tone)) {
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
: [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}` : '',
'系统预审规则命中该风险提示。'
])
: [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}`,
@@ -317,7 +462,7 @@ export function buildAttachmentRiskCards({
risk,
summary,
ruleBasis,
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary })
}))
})
.filter(Boolean)
@@ -328,6 +473,52 @@ export function buildAttachmentRiskCards({
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)