fix(expense): narrow travel route risk indicators

This commit is contained in:
caoxiaozhu
2026-06-17 09:36:24 +08:00
parent 9f7b8b46a3
commit 470f343b29
10 changed files with 1040 additions and 368 deletions

View File

@@ -1013,7 +1013,11 @@
box-sizing: border-box !important;
}
.editor-control:not(.risk-note-editor-input),
.editor-select {
min-height: var(--expense-editor-control-height);
height: var(--expense-editor-control-height);
}
.editor-select {
padding: 0;

View File

@@ -0,0 +1,124 @@
function normalizeText(value) {
return String(value || '').trim()
}
function cardLikeText(card = {}) {
if (!card || typeof card !== 'object') {
return normalizeText(card)
}
return [
card.title,
card.label,
card.name,
card.summary,
card.message,
card.reason,
card.suggestion,
card.ruleName,
card.rule_name,
card.ruleCode,
card.rule_code,
card.evidence?.summary,
card.evidence?.reason
].map((value) => normalizeText(value)).filter(Boolean).join(' ')
}
export 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 ''
}
export 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)
}
export function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
const text = normalizeText(value)
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
return 'reimbursement'
}
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
return 'expense_application'
}
return fallback
}
export 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'
}

View File

@@ -8,6 +8,13 @@ import {
resolveRiskDomain,
resolveRiskVisibilityScope
} from '../../utils/riskVisibility.js'
import {
normalizeBusinessStage,
resolveFlagBusinessStage,
resolveRequestBusinessStage,
resolveRiskTextBusinessStage
} from './travelRequestDetailBusinessStage.js'
import { resolveRouteRelatedItemIdsForRisk } from './travelRequestDetailRouteRisk.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
@@ -46,68 +53,6 @@ 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,
@@ -121,46 +66,6 @@ function cardLikeText(card = {}) {
].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'
@@ -587,43 +492,6 @@ function isCoveredByAttachmentHotelOverStandardRisk(flag, attachmentCards = [])
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 = {},
@@ -731,9 +599,12 @@ export function buildAttachmentRiskCards({
const risks = flagPoints.length
? flagPoints
: [primaryRisk || fallbackRisk].filter(Boolean)
const relatedItemIds = flagItemIds.length
? flagItemIds
: inferRelatedItemIdsForRisk(flag, risks, expenseItems)
const relatedItemIds = resolveRouteRelatedItemIdsForRisk({
flagItemIds,
flag,
risks,
expenseItems
})
const itemIndex = Number(flag.item_index ?? flag.itemIndex ?? 0) || null
const title = normalizeRiskCardTitle(
flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code,

View File

@@ -0,0 +1,197 @@
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeId(value) {
return String(value || '').trim()
}
function cardLikeText(card = {}) {
if (!card || typeof card !== 'object') {
return normalizeText(card)
}
return [
card.title,
card.label,
card.name,
card.summary,
card.message,
card.reason,
card.suggestion,
card.ruleName,
card.rule_name,
card.ruleCode,
card.rule_code,
card.evidence?.summary,
card.evidence?.reason
].map((value) => normalizeText(value)).filter(Boolean).join(' ')
}
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)
}
const GENERIC_ROUTE_CITY_TOKENS = new Set([
'起始地',
'目的地',
'出发地',
'返回地',
'中转地',
'城市',
'地点'
])
function normalizeRouteCityToken(value) {
const text = normalizeText(value).replace(/[,。;、]/g, '').replace(/市$/, '')
if (!text || GENERIC_ROUTE_CITY_TOKENS.has(text)) {
return ''
}
return text
}
function extractRouteCityPairFromText(value) {
const text = normalizeText(value)
if (!text) {
return null
}
const match = text.match(/([\u4e00-\u9fa5]{2,8})\s*(?:市)?\s*[--—–~~至到]\s*([\u4e00-\u9fa5]{2,8})\s*(?:市)?/)
if (!match) {
return null
}
const origin = normalizeRouteCityToken(match[1])
const destination = normalizeRouteCityToken(match[2])
if (!origin || !destination || origin === destination) {
return null
}
return { origin, destination }
}
function resolveTravelRouteInfo(item = {}) {
if (!normalizeId(item?.id) || !isTravelRouteExpenseItem(item)) {
return null
}
const pair = [
item.desc,
item.itemReason,
item.item_reason,
item.itemLocation,
item.item_location,
item.detail
]
.map((value) => extractRouteCityPairFromText(value))
.find(Boolean)
if (!pair) {
return null
}
return {
id: normalizeId(item.id),
cities: [pair.origin, pair.destination],
origin: pair.origin,
destination: pair.destination
}
}
function uniqueTextList(values) {
return (Array.isArray(values) ? values : [])
.map((value) => normalizeText(value))
.filter(Boolean)
.filter((value, index, list) => list.indexOf(value) === index)
}
function resolveRoundTripBaseCities(routeInfos) {
if (!Array.isArray(routeInfos) || routeInfos.length < 2) {
return new Set()
}
const first = routeInfos[0]
const last = routeInfos[routeInfos.length - 1]
if (
first?.origin
&& first?.destination
&& last?.origin
&& last?.destination
&& first.origin === last.destination
&& first.destination === last.origin
) {
return new Set([first.origin, first.destination])
}
return new Set()
}
function resolveUnexpectedRouteCitiesForRisk(text, routeInfos) {
const routeCities = uniqueTextList(routeInfos.flatMap((item) => item.cities))
if (!routeCities.length) {
return []
}
const baseCities = resolveRoundTripBaseCities(routeInfos)
const mentionedCities = routeCities.filter((city) => text.includes(city))
const mentionedExtraCities = mentionedCities.filter((city) => !baseCities.has(city))
if (mentionedExtraCities.length) {
return mentionedExtraCities
}
if (baseCities.size) {
return routeCities.filter((city) => !baseCities.has(city))
}
return mentionedCities
}
function inferRouteRelatedItemIds(flag, risks, expenseItems) {
const text = [
cardLikeText(flag),
...uniqueTextList(risks)
].map((value) => normalizeText(value)).join(' ')
if (!isRouteLevelRiskText(text)) {
return []
}
const routeInfos = (Array.isArray(expenseItems) ? expenseItems : [])
.map((item) => resolveTravelRouteInfo(item))
.filter(Boolean)
const unexpectedCities = resolveUnexpectedRouteCitiesForRisk(text, routeInfos)
if (!unexpectedCities.length) {
return []
}
return routeInfos
.filter((item) => item.cities.some((city) => unexpectedCities.includes(city)))
.map((item) => item.id)
}
export function resolveRouteRelatedItemIdsForRisk({
flagItemIds = [],
flag = {},
risks = [],
expenseItems = []
} = {}) {
const normalizedFlagItemIds = (Array.isArray(flagItemIds) ? flagItemIds : [])
.map((itemId) => normalizeId(itemId))
.filter(Boolean)
const inferredItemIds = inferRouteRelatedItemIds(flag, risks, expenseItems)
if (!normalizedFlagItemIds.length) {
return inferredItemIds
}
if (
inferredItemIds.length
&& inferredItemIds.length < normalizedFlagItemIds.length
&& inferredItemIds.every((itemId) => normalizedFlagItemIds.includes(itemId))
) {
return inferredItemIds
}
return normalizedFlagItemIds
}

View File

@@ -659,6 +659,22 @@ test('legacy route-level risk cards infer affected travel rows when backend has
detail: '起始地-目的地',
invoiceId: 'transfer.png'
},
{
id: 'train-transfer-return',
name: '火车票',
category: '火车票',
desc: '深圳-上海',
detail: '起始地-目的地',
invoiceId: 'transfer-return.png'
},
{
id: 'train-return',
name: '火车票',
category: '火车票',
desc: '上海-武汉',
detail: '起始地-目的地',
invoiceId: 'return.png'
},
{
id: 'allowance-row',
name: '出差补贴',
@@ -678,7 +694,59 @@ test('legacy route-level risk cards infer affected travel rows when backend has
})
assert.equal(riskCards.length, 1)
assert.deepEqual(riskCards[0].itemIds, ['train-outbound', 'train-transfer'])
assert.deepEqual(riskCards[0].itemIds, ['train-transfer', 'train-transfer-return'])
})
test('route-level risk cards narrow broad backend item ids to abnormal route rows', () => {
const expenseItems = [
{
id: 'train-outbound',
name: '火车票',
category: '火车票',
desc: '武汉-上海',
detail: '起始地-目的地',
invoiceId: 'outbound.png'
},
{
id: 'train-transfer',
name: '火车票',
category: '火车票',
desc: '上海-深圳',
detail: '起始地-目的地',
invoiceId: 'transfer.png'
},
{
id: 'train-transfer-return',
name: '火车票',
category: '火车票',
desc: '深圳-上海',
detail: '起始地-目的地',
invoiceId: 'transfer-return.png'
},
{
id: 'train-return',
name: '火车票',
category: '火车票',
desc: '上海-武汉',
detail: '起始地-目的地',
invoiceId: 'return.png'
}
]
const riskCards = buildAttachmentRiskCards({
expenseItems,
claimRiskFlags: [
{
source: 'submission_review',
severity: 'high',
label: '多城市行程待说明',
message: '本次报销识别到多城市行程(上海、武汉、深圳),但事由中未说明中转、多地拜访或改签原因。',
item_ids: expenseItems.map((item) => item.id)
}
]
})
assert.equal(riskCards.length, 1)
assert.deepEqual(riskCards[0].itemIds, ['train-transfer', 'train-transfer-return'])
})
test('AI advice shows only the latest manual return while preserving return count context', () => {