fix(expense): narrow travel route risk indicators
This commit is contained in:
@@ -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;
|
||||
|
||||
124
web/src/views/scripts/travelRequestDetailBusinessStage.js
Normal file
124
web/src/views/scripts/travelRequestDetailBusinessStage.js
Normal 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'
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
197
web/src/views/scripts/travelRequestDetailRouteRisk.js
Normal file
197
web/src/views/scripts/travelRequestDetailRouteRisk.js
Normal 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
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user