feat: 风险可见性控制与差旅详情页交互优化
- 新增风险可见性工具函数与风险日趋势图表组件 - 优化差旅请求详情页费用模型与视图交互 - 完善顶部导航栏样式与应用壳路由逻辑 - 补充风险可见性、风险看板与差旅详情测试覆盖
This commit is contained in:
@@ -78,18 +78,28 @@ test('documents center reloads immediately when entered or clicked again', () =>
|
||||
test('document detail navigation preserves document center list query', () => {
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/function openRequestDetail\(request\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: \{ \.\.\.route\.query \}/
|
||||
/function openRequestDetail\(request, options = \{\}\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: buildDocumentDetailQuery\(options\)/
|
||||
)
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
|
||||
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/
|
||||
)
|
||||
assert.match(
|
||||
appShellComposable,
|
||||
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
|
||||
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: buildDocumentReturnQuery\(\) \}\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('document detail refreshes claim detail instead of relying on stale list cache', () => {
|
||||
assert.match(appShellComposable, /import \{ fetchExpenseClaimDetail \} from '\.\.\/services\/reimbursements\.js'/)
|
||||
assert.match(appShellComposable, /import \{ mapExpenseClaimToRequest, useRequests \} from '\.\/useRequests\.js'/)
|
||||
assert.match(appShellComposable, /const snapshot = normalizeRequestForUi\(selectedRequestSnapshot\.value\)[\s\S]*if \(isSameRequestIdentity\(snapshot, requestId\)\) \{[\s\S]*return snapshot/)
|
||||
assert.match(appShellComposable, /async function refreshSelectedRequestDetail\(requestOrId = selectedRequestSnapshot\.value\) \{[\s\S]*fetchExpenseClaimDetail\(lookupId\)[\s\S]*mapExpenseClaimToRequest\(payload\)[\s\S]*upsertRequestSnapshot\(mappedRequest\)/)
|
||||
assert.match(appShellComposable, /function openRequestDetail\(request, options = \{\}\) \{[\s\S]*void refreshSelectedRequestDetail\(request\)/)
|
||||
assert.match(appShellComposable, /async function handleRequestUpdated\(\) \{[\s\S]*await reloadRequests\(\)[\s\S]*await refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||
assert.match(appShellComposable, /route\.name === 'app-document-detail'[\s\S]*void refreshSelectedRequestDetail\(String\(route\.params\.requestId \|\| ''\)\)/)
|
||||
})
|
||||
|
||||
test('application entry keeps its own assistant source without creating a separate dialog', () => {
|
||||
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
|
||||
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
|
||||
|
||||
@@ -16,6 +16,10 @@ const dashboardComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const riskDailyTrendChart = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/charts/RiskDailyTrendChart.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const overviewViewModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -24,6 +28,18 @@ const overviewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const topBarComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appShellComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const legacyAppScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/scripts/App.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const riskLabels = readFileSync(
|
||||
fileURLToPath(new URL('../src/utils/riskLabels.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -118,6 +134,28 @@ test('risk dashboard follows the top overview range without card-level selectors
|
||||
assert.match(dashboardComponent, /RiskDailyTrendChart/)
|
||||
})
|
||||
|
||||
test('overview custom date defaults use current year instead of hard-coded legacy dates', () => {
|
||||
assert.match(topBarComponent, /createCurrentYearDateRange/)
|
||||
assert.match(topBarComponent, /formatDateValue/)
|
||||
assert.match(appShellComposable, /createCurrentYearDateRange\(\)/)
|
||||
assert.match(legacyAppScript, /createCurrentYearDateRange\(\)/)
|
||||
assert.doesNotMatch(topBarComponent, /2024-07-06|2024-07-12/)
|
||||
assert.doesNotMatch(appShellComposable, /2024-07-06|2024-07-12/)
|
||||
assert.doesNotMatch(legacyAppScript, /2024-07-06|2024-07-12/)
|
||||
})
|
||||
|
||||
test('risk daily trend is bucketed for long ranges and keeps chart labels readable', () => {
|
||||
assert.match(overviewViewModel, /RISK_DAILY_TREND_MAX_BUCKETS = 14/)
|
||||
assert.match(overviewViewModel, /aggregateRiskDailyTrendRows/)
|
||||
assert.match(overviewViewModel, /Math\.ceil\(normalizedRows\.length \/ maxBuckets\)/)
|
||||
assert.match(overviewViewModel, /buildRiskTrendBucketLabel/)
|
||||
assert.doesNotMatch(overviewViewModel, /rows\.slice\(-7\)/)
|
||||
assert.match(riskDailyTrendChart, /const barWidth = computed/)
|
||||
assert.match(riskDailyTrendChart, /barMaxWidth: 14/)
|
||||
assert.match(riskDailyTrendChart, /bottomGridSize/)
|
||||
assert.match(riskDailyTrendChart, /replace\('~', '\\n~'\)/)
|
||||
})
|
||||
|
||||
test('risk dashboard shows loading overlay and realtime refresh status', () => {
|
||||
assert.match(overviewTemplate, /dashboard-loading-state/)
|
||||
assert.match(overviewTemplate, /floating/)
|
||||
|
||||
@@ -105,6 +105,43 @@ test('reimbursement submitter sees only fixable claim risks', () => {
|
||||
assert.deepEqual(visibleCards.map((card) => card.id), ['ticket-date'])
|
||||
})
|
||||
|
||||
test('reimbursement detail still shows submitter-fixable attachment risks when viewer identity is incomplete', () => {
|
||||
const request = {
|
||||
id: 'RE-20260603083825-876B85XW',
|
||||
claimId: '2ad80b25-b153-407e-91be-ed2651045fb1',
|
||||
documentTypeCode: 'claim',
|
||||
approvalKey: 'draft',
|
||||
node: 'pending-submit',
|
||||
employeeId: 'EMP-CLAIM-OWNER',
|
||||
typeCode: 'travel',
|
||||
expenseItems: []
|
||||
}
|
||||
const currentUserWithoutEmployeeMatch = {
|
||||
id: 'FRONTEND-AUTH-SNAPSHOT',
|
||||
employeeId: '',
|
||||
name: '',
|
||||
roleCodes: []
|
||||
}
|
||||
const cards = [
|
||||
{
|
||||
id: 'hotel-limit-risk',
|
||||
source: 'attachment_analysis',
|
||||
businessStage: 'reimbursement',
|
||||
tone: 'high',
|
||||
risk: 'hotel limit exceeded',
|
||||
risk_domain: 'invoice',
|
||||
visibility_scope: 'submitter',
|
||||
actionability: 'fixable_by_submitter'
|
||||
}
|
||||
]
|
||||
|
||||
const visibleCards = filterRiskCardsForVisibility(cards, {
|
||||
request,
|
||||
currentUser: currentUserWithoutEmployeeMatch
|
||||
})
|
||||
assert.deepEqual(visibleCards.map((card) => card.id), ['hotel-limit-risk'])
|
||||
})
|
||||
|
||||
test('finance can see reimbursement compliance risks but not budget governance detail', () => {
|
||||
const request = {
|
||||
id: 'RE-202606010002',
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
buildExpenseItemViewModel,
|
||||
buildDraftBlockingIssues,
|
||||
rebuildExpenseItems,
|
||||
buildStandardAdjustmentMap,
|
||||
isApplicationDocumentRequest
|
||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||
@@ -543,9 +544,13 @@ test('AI advice risk section uses compact card styling hooks', () => {
|
||||
|
||||
test('expense rows show a major-risk warning icon before time', () => {
|
||||
assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
|
||||
assert.match(detailViewTemplate, /class="expense-time-content"/)
|
||||
assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
|
||||
assert.match(detailViewTemplate, /class="expense-risk-indicator-placeholder"/)
|
||||
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
|
||||
assert.match(detailViewStyle, /\.expense-time-content \{/)
|
||||
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
|
||||
assert.match(detailViewStyle, /\.expense-risk-indicator,\s*\.expense-risk-indicator-placeholder \{/)
|
||||
assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
|
||||
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
|
||||
})
|
||||
@@ -556,7 +561,7 @@ test('expense risk indicator can focus and flash related risk card', () => {
|
||||
assert.match(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
|
||||
assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
|
||||
assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/)
|
||||
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'center' \}\)/)
|
||||
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'nearest', inline: 'nearest' \}\)/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
|
||||
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
|
||||
})
|
||||
@@ -748,6 +753,50 @@ test('expense detail shows standard-adjusted reimbursable amount separately from
|
||||
assert.equal(item.hasStandardAdjustment, true)
|
||||
})
|
||||
|
||||
test('plain reimbursable amount does not mark an item as standard-adjusted during detail rebuild', () => {
|
||||
const item = buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'hotel-risk-item',
|
||||
itemType: 'hotel_ticket',
|
||||
itemReason: '上海住宿',
|
||||
itemAmount: 1086,
|
||||
reimbursableAmount: 1086,
|
||||
originalItemAmount: 1086,
|
||||
invoiceId: 'hotel-risk.jpg',
|
||||
standardAdjustmentAccepted: false,
|
||||
hasStandardAdjustment: false
|
||||
},
|
||||
0,
|
||||
{ riskFlags: [] }
|
||||
)
|
||||
|
||||
assert.equal(item.standardAdjustmentAccepted, false)
|
||||
assert.equal(item.hasStandardAdjustment, false)
|
||||
assert.equal(item.reimbursableAmount, 1086)
|
||||
|
||||
const rebuiltItems = rebuildExpenseItems([item], { riskFlags: [] })
|
||||
assert.equal(rebuiltItems[0].standardAdjustmentAccepted, false)
|
||||
assert.equal(rebuiltItems[0].hasStandardAdjustment, false)
|
||||
|
||||
const riskCards = [
|
||||
{
|
||||
id: 'hotel-risk',
|
||||
source: 'attachment_analysis',
|
||||
itemId: 'hotel-risk-item',
|
||||
tone: 'high',
|
||||
risk: '住宿标准超标。'
|
||||
}
|
||||
]
|
||||
const visibleCards = filterSubmitterResolvedRiskCards({
|
||||
cards: riskCards,
|
||||
businessStage: 'reimbursement',
|
||||
isCurrentApplicant: true,
|
||||
expenseItems: rebuiltItems,
|
||||
standardAdjustmentMap: new Map()
|
||||
})
|
||||
assert.deepEqual(visibleCards.map((card) => card.id), ['hotel-risk'])
|
||||
})
|
||||
|
||||
test('standard adjustment resolves submitter risk prompt only after accepted while reviewer still sees notice', () => {
|
||||
const originalRiskCard = {
|
||||
id: 'risk-hotel-1',
|
||||
|
||||
Reference in New Issue
Block a user