fix(claim): align risk advice with expense rows
This commit is contained in:
@@ -1657,7 +1657,15 @@ export default {
|
||||
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
|
||||
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||||
|
||||
return actionableCards.find((card) => String(card?.itemId || card?.item_id || '').trim() === itemId)
|
||||
return actionableCards.find((card) => {
|
||||
const cardItemIds = [
|
||||
card?.itemId,
|
||||
card?.item_id,
|
||||
...(Array.isArray(card?.itemIds) ? card.itemIds : []),
|
||||
...(Array.isArray(card?.item_ids) ? card.item_ids : [])
|
||||
].map((value) => String(value || '').trim()).filter(Boolean)
|
||||
return cardItemIds.includes(itemId)
|
||||
})
|
||||
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|
||||
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|
||||
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`第 ${itemIndex} 条`))
|
||||
|
||||
@@ -181,6 +181,56 @@ function normalizeId(value) {
|
||||
return normalizeText(value)
|
||||
}
|
||||
|
||||
function normalizeIdList(value) {
|
||||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: normalizeText(value)
|
||||
? [value]
|
||||
: []
|
||||
return [...new Set(rawValues.map((item) => normalizeId(item)).filter(Boolean))]
|
||||
}
|
||||
|
||||
function buildExpenseItemIndexMap(expenseItems = []) {
|
||||
const itemIndexById = new Map()
|
||||
;(Array.isArray(expenseItems) ? expenseItems : []).forEach((item, index) => {
|
||||
const itemId = normalizeId(item?.id)
|
||||
if (itemId && !itemIndexById.has(itemId)) {
|
||||
itemIndexById.set(itemId, index + 1)
|
||||
}
|
||||
})
|
||||
return itemIndexById
|
||||
}
|
||||
|
||||
function resolveRiskItemNumbers({ itemId = '', itemIds = [], itemIndex = null } = {}, expenseItems = []) {
|
||||
const itemIndexById = buildExpenseItemIndexMap(expenseItems)
|
||||
const itemNumbers = []
|
||||
const explicitItemIndex = Number(itemIndex)
|
||||
if (Number.isFinite(explicitItemIndex) && explicitItemIndex > 0) {
|
||||
itemNumbers.push(Math.floor(explicitItemIndex))
|
||||
}
|
||||
|
||||
const relatedItemIds = uniqueTexts([
|
||||
normalizeId(itemId),
|
||||
...normalizeIdList(itemIds)
|
||||
])
|
||||
relatedItemIds.forEach((relatedItemId) => {
|
||||
const resolvedIndex = itemIndexById.get(relatedItemId)
|
||||
if (resolvedIndex) {
|
||||
itemNumbers.push(resolvedIndex)
|
||||
}
|
||||
})
|
||||
|
||||
return [...new Set(itemNumbers)].sort((left, right) => left - right)
|
||||
}
|
||||
|
||||
function buildRiskTitleWithItemNumbers(title, itemNumbers = []) {
|
||||
const cleanTitle = normalizeText(title) || '单据风险提示'
|
||||
if (!itemNumbers.length || /^第\s*[\d、,,\s]+\s*条[::]/.test(cleanTitle)) {
|
||||
return cleanTitle
|
||||
}
|
||||
return `第 ${itemNumbers.join('、')} 条:${cleanTitle}`
|
||||
}
|
||||
|
||||
function resolveItemRiskFlag(item, claimRiskFlags) {
|
||||
const itemId = normalizeId(item?.id)
|
||||
if (!itemId || !Array.isArray(claimRiskFlags)) {
|
||||
@@ -197,8 +247,9 @@ function resolveItemRiskFlag(item, claimRiskFlags) {
|
||||
}
|
||||
|
||||
const flagItemId = normalizeId(flag.item_id || flag.itemId)
|
||||
const flagItemIds = normalizeIdList(flag.item_ids || flag.itemIds)
|
||||
const tone = resolveFlagTone(flag)
|
||||
return flagItemId === itemId && isRiskTone(tone)
|
||||
return (flagItemId === itemId || flagItemIds.includes(itemId)) && isRiskTone(tone)
|
||||
}) || null
|
||||
}
|
||||
|
||||
@@ -526,6 +577,78 @@ function buildManualReturnRiskCard(flag, businessStage = 'reimbursement') {
|
||||
})
|
||||
}
|
||||
|
||||
function isGenericAutoReviewSummaryFlag(flag) {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
const label = normalizeText(flag.label || flag.title || flag.name)
|
||||
if (source !== 'submission_review' || !['自动检测重点复核', '自动检测提醒'].includes(label)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const hasConcreteRule = normalizeText(flag.rule_code || flag.ruleCode || flag.hit_source || flag.hitSource)
|
||||
const hasConcreteItem = (
|
||||
normalizeText(flag.item_id || flag.itemId || flag.invoice_id || flag.invoiceId)
|
||||
|| normalizeIdList(flag.item_ids || flag.itemIds).length
|
||||
)
|
||||
if (hasConcreteRule || hasConcreteItem) {
|
||||
return false
|
||||
}
|
||||
|
||||
return /^自动检测发现/.test(normalizeText(flag.message || flag.summary || flag.reason))
|
||||
}
|
||||
|
||||
function isHotelOverStandardRiskText(value) {
|
||||
const text = normalizeText(value)
|
||||
return /住宿|酒店|宾馆/.test(text) && /超标|超出|报销标准|住宿标准|差标/.test(text)
|
||||
}
|
||||
|
||||
function isCoveredByAttachmentHotelOverStandardRisk(flag, attachmentCards = []) {
|
||||
if (!isHotelOverStandardRiskText(cardLikeText(flag))) {
|
||||
return false
|
||||
}
|
||||
return attachmentCards.some((card) => isHotelOverStandardRiskText(cardLikeText(card)))
|
||||
}
|
||||
|
||||
function isRouteLevelRiskText(value) {
|
||||
const text = normalizeText(value)
|
||||
return /行程|多城市|目的地|票据城市|差旅地点|中转|改签|异地/.test(text)
|
||||
}
|
||||
|
||||
function isTravelRouteExpenseItem(item = {}) {
|
||||
const text = [
|
||||
item.name,
|
||||
item.category,
|
||||
item.desc,
|
||||
item.detail,
|
||||
item.itemType,
|
||||
item.item_type
|
||||
].map((value) => normalizeText(value)).join(' ')
|
||||
if (/补贴|系统自动计算/.test(text)) {
|
||||
return false
|
||||
}
|
||||
return /火车|高铁|机票|航班|交通票|出发|返回|中转|起始地|目的地|[--—~至]/.test(text)
|
||||
}
|
||||
|
||||
function inferRelatedItemIdsForRisk(flag, risks, expenseItems) {
|
||||
const text = [
|
||||
cardLikeText(flag),
|
||||
...listRiskTextValues(risks)
|
||||
].map((value) => normalizeText(value)).join(' ')
|
||||
if (!isRouteLevelRiskText(text)) {
|
||||
return []
|
||||
}
|
||||
return (Array.isArray(expenseItems) ? expenseItems : [])
|
||||
.filter((item) => normalizeId(item?.id) && isTravelRouteExpenseItem(item))
|
||||
.map((item) => normalizeId(item.id))
|
||||
}
|
||||
|
||||
function listRiskTextValues(risks) {
|
||||
return Array.isArray(risks) ? risks : []
|
||||
}
|
||||
|
||||
export function buildAttachmentRiskCards({
|
||||
expenseItems = [],
|
||||
attachmentMetaByItemId = {},
|
||||
@@ -581,6 +704,12 @@ export function buildAttachmentRiskCards({
|
||||
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'ai_pre_review') {
|
||||
return []
|
||||
}
|
||||
if (isGenericAutoReviewSummaryFlag(flag)) {
|
||||
return []
|
||||
}
|
||||
if (isCoveredByAttachmentHotelOverStandardRisk(flag, attachmentCards)) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
if (!isActionableRiskFlag(flag)) {
|
||||
@@ -609,6 +738,7 @@ export function buildAttachmentRiskCards({
|
||||
|
||||
const source = normalizeText(flag.source)
|
||||
const flagItemId = normalizeId(flag.item_id || flag.itemId)
|
||||
const flagItemIds = normalizeIdList(flag.item_ids || flag.itemIds)
|
||||
if (source === 'attachment_analysis' && flagItemId && attachmentRiskItemIds.has(flagItemId)) {
|
||||
return []
|
||||
}
|
||||
@@ -626,6 +756,19 @@ export function buildAttachmentRiskCards({
|
||||
const risks = flagPoints.length
|
||||
? flagPoints
|
||||
: [primaryRisk || fallbackRisk].filter(Boolean)
|
||||
const relatedItemIds = flagItemIds.length
|
||||
? flagItemIds
|
||||
: inferRelatedItemIdsForRisk(flag, risks, expenseItems)
|
||||
const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null
|
||||
const relatedItemNumbers = resolveRiskItemNumbers({
|
||||
itemId: flagItemId,
|
||||
itemIds: relatedItemIds,
|
||||
itemIndex
|
||||
}, expenseItems)
|
||||
const title = buildRiskTitleWithItemNumbers(
|
||||
normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
|
||||
relatedItemNumbers
|
||||
)
|
||||
const summary = normalizeText(flag.summary || flag.message || flag.reason)
|
||||
const ruleBasis = resolveClaimRiskRuleBasis(flag, {
|
||||
risk: risks[0] || primaryRisk || fallbackRisk,
|
||||
@@ -635,13 +778,14 @@ export function buildAttachmentRiskCards({
|
||||
|
||||
return risks.map((risk, pointIndex) => withRiskTags({
|
||||
id: `claim-risk-${index}-${pointIndex}`,
|
||||
itemId: flagItemId,
|
||||
itemIndex: Number(flag.item_index ?? flag.itemIndex ?? 0) || null,
|
||||
itemId: flagItemId || (relatedItemIds.length === 1 ? relatedItemIds[0] : ''),
|
||||
itemIds: relatedItemIds,
|
||||
itemIndex,
|
||||
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) || '单据风险提示',
|
||||
title,
|
||||
risk,
|
||||
summary,
|
||||
ruleBasis,
|
||||
|
||||
@@ -393,6 +393,55 @@ test('attachment risk cards do not duplicate claim fallback flags for the same i
|
||||
assert.equal(riskCards[0].risk, '住宿标准:当前酒店识别金额约 880.00 元/晚。')
|
||||
})
|
||||
|
||||
test('AI advice hides generic auto review summaries when a specific hotel over-standard risk exists', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'hotel-over-standard-item',
|
||||
name: '住宿票',
|
||||
itemType: 'hotel_ticket',
|
||||
invoiceId: 'hotel-over-standard.png'
|
||||
}
|
||||
],
|
||||
attachmentMetaByItemId: {
|
||||
'hotel-over-standard-item': {
|
||||
analysis: {
|
||||
severity: 'high',
|
||||
label: '高风险',
|
||||
headline: 'AI提示:住宿金额超出报销标准',
|
||||
summary: '当前住宿票据金额超过规则中心差旅住宿标准。',
|
||||
points: ['住宿标准:P5在上海的住宿标准为 250.00 元/晚,当前酒店识别金额约 362.00 元/晚。'],
|
||||
suggestion: '请补充超标说明。'
|
||||
}
|
||||
}
|
||||
},
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
severity: 'high',
|
||||
label: '自动检测重点复核',
|
||||
message: '自动检测发现 1 条高风险附件,已随单流转给审批人重点复核。'
|
||||
},
|
||||
{
|
||||
source: 'submission_review',
|
||||
severity: 'high',
|
||||
label: '住宿超标待说明',
|
||||
message: 'P5 职级在上海的住宿标准为 250.00 元/晚,当前酒店识别金额约 362.00 元/晚。 当前未识别到超标说明,请先补充原因。'
|
||||
},
|
||||
{
|
||||
source: 'submission_review',
|
||||
severity: 'medium',
|
||||
label: '自动检测重点复核',
|
||||
message: '自动检测发现需审批重点关注事项:存在高风险票据,需审批人重点复核;住宿金额超出当前职级差标,且未补充超标说明。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.equal(riskCards[0].title, '第 1 条:AI提示:住宿金额超出报销标准')
|
||||
assert.equal(riskCards[0].tone, 'high')
|
||||
})
|
||||
|
||||
test('AI advice view model exposes grouped completion and risk sections', () => {
|
||||
const advice = buildAiAdviceViewModel({
|
||||
completionItems: ['补充业务地点', '补充报销金额'],
|
||||
@@ -567,6 +616,71 @@ test('expense risk indicator can focus and flash related risk card', () => {
|
||||
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
|
||||
})
|
||||
|
||||
test('route-level risk cards keep related item ids for every affected expense row', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
{ id: 'travel-item-1', name: '火车票', category: '火车票' },
|
||||
{ id: 'travel-item-2', name: '火车票', category: '火车票' },
|
||||
{ id: 'travel-item-3', name: '火车票', category: '火车票' }
|
||||
],
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
severity: 'high',
|
||||
label: '多城市行程待说明',
|
||||
message: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。',
|
||||
item_ids: ['travel-item-2', 'travel-item-3']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.deepEqual(riskCards[0].itemIds, ['travel-item-2', 'travel-item-3'])
|
||||
assert.equal(riskCards[0].title, '第 2、3 条:多城市行程待说明')
|
||||
assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/)
|
||||
})
|
||||
|
||||
test('legacy route-level risk cards infer affected travel rows when backend has no item ids', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'train-outbound',
|
||||
name: '火车票',
|
||||
category: '火车票',
|
||||
desc: '武汉-上海',
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'outbound.png'
|
||||
},
|
||||
{
|
||||
id: 'train-transfer',
|
||||
name: '火车票',
|
||||
category: '火车票',
|
||||
desc: '上海-深圳',
|
||||
detail: '起始地-目的地',
|
||||
invoiceId: 'transfer.png'
|
||||
},
|
||||
{
|
||||
id: 'allowance-row',
|
||||
name: '出差补贴',
|
||||
category: '出差补贴',
|
||||
desc: '系统自动计算',
|
||||
detail: '直辖市/特区'
|
||||
}
|
||||
],
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
severity: 'high',
|
||||
label: '多城市行程待说明',
|
||||
message: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.deepEqual(riskCards[0].itemIds, ['train-outbound', 'train-transfer'])
|
||||
})
|
||||
|
||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
|
||||
Reference in New Issue
Block a user