test: 同步报销审批流与预算分析测试
- 新增预算审批合并、风险标记去重与占位条目校验用例 - 补充预算分析对当前审核人与财务的可见性断言 - 调整单据删除权限测试以匹配 admin 限制
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
resolveRiskTagTone
|
||||
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||
import {
|
||||
buildExpenseDraftIssues,
|
||||
buildExpenseItemViewModel,
|
||||
buildDraftBlockingIssues,
|
||||
rebuildExpenseItems,
|
||||
@@ -27,7 +28,8 @@ import {
|
||||
} from '../src/views/scripts/travelRequestDetailAdviceModel.js'
|
||||
import {
|
||||
buildStandardAdjustmentPayload,
|
||||
filterSubmitterResolvedRiskCards
|
||||
filterSubmitterResolvedRiskCards,
|
||||
isRiskCardMissingExpenseNote
|
||||
} from '../src/views/scripts/travelRequestDetailStandardAdjustment.js'
|
||||
|
||||
const detailViewTemplate = readFileSync(
|
||||
@@ -70,6 +72,10 @@ const stageRiskAdviceCard = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stageRiskAdviceStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/stage-risk-advice-card.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const attachmentMeta = {
|
||||
file_name: 'taxi-invoice.pdf',
|
||||
@@ -291,20 +297,56 @@ test('risk cards carry structured business stage for approval advice filtering',
|
||||
assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), [])
|
||||
})
|
||||
|
||||
test('stage risk advice card exposes direct reviewer action suggestion', () => {
|
||||
assert.match(stageRiskAdviceCard, /class="employee-risk-action"/)
|
||||
test('stage risk advice card focuses on document risks without profile or budget boards', () => {
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-decision-panel/)
|
||||
assert.match(stageRiskAdviceCard, /综合审核结论/)
|
||||
assert.match(stageRiskAdviceCard, /建议结论/)
|
||||
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
|
||||
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
|
||||
assert.match(stageRiskAdviceCard, /compactAdviceItems/)
|
||||
assert.ok(
|
||||
stageRiskAdviceCard.indexOf('class="employee-risk-advice-list"')
|
||||
< stageRiskAdviceCard.indexOf('class="employee-risk-action"')
|
||||
)
|
||||
assert.match(stageRiskAdviceCard, /stageBasisTitle/)
|
||||
assert.match(stageRiskAdviceCard, /stageBasisHint/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-profile-section/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-profile-list/)
|
||||
assert.match(stageRiskAdviceCard, /classifyReimbursementRiskCards/)
|
||||
assert.match(stageRiskAdviceCard, /stripEmbeddedExplanationText/)
|
||||
assert.match(stageRiskAdviceCard, /if \(summary\) \{[\s\S]*return \[`已补充异常说明:\$\{summary\}`\]/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
||||
assert.match(stageRiskAdviceCard, /\.employee-risk-ai-note \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/)
|
||||
assert.match(stageRiskAdviceCard, /\.employee-risk-action \{[\s\S]*align-items: center;[\s\S]*justify-content: center;[\s\S]*text-align: center;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-decision-panel \{[\s\S]*grid-template-columns: minmax\(0, 1fr\) minmax\(220px, 32%\);/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-profile-list \{[\s\S]*grid-template-columns: 1fr;/)
|
||||
assert.match(stageRiskAdviceStyles, /\.employee-risk-evidence-row li \{[\s\S]*white-space: normal;/)
|
||||
assert.doesNotMatch(stageRiskAdviceStyles, /grid-row: span 2/)
|
||||
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
|
||||
assert.match(stageRiskAdviceCard, /riskExplanationItems/)
|
||||
assert.match(stageRiskAdviceCard, /请核对已补充说明是否覆盖风险点/)
|
||||
assert.match(stageRiskAdviceCard, /已补充异常说明/)
|
||||
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
|
||||
assert.match(stageRiskAdviceCard, /申请单风险依据/)
|
||||
assert.match(stageRiskAdviceCard, /报销单风险依据/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /人员行为画像/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /部门预算执行/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /title: '说明与佐证'/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /fetchExpenseClaimBudgetAnalysis/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /reviewDimensionCards/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /documentRiskMetrics/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /profileAdviceItems/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /profileContextItems/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /画像风险/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /退单\/补正/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /材料质量/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /申请人:/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /部门\/岗位:/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /budgetContextMetrics/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /BUDGET_FIELD_KEYS/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /预算池/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /未匹配预算池/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /科目未管控/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /占用比例/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /剩余比例/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /超预算风险/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /explanationContextMetrics/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /employee-risk-context-grid/)
|
||||
assert.doesNotMatch(stageRiskAdviceCard, /class="employee-risk-action"/)
|
||||
assert.doesNotMatch(stageRiskAdviceStyles, /employee-risk-ai-note/)
|
||||
})
|
||||
|
||||
test('AI advice ignores approval opinions and flow logs as risks', () => {
|
||||
@@ -489,6 +531,42 @@ test('AI advice view model sorts and displays every risk card', () => {
|
||||
assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3'])
|
||||
})
|
||||
|
||||
test('AI advice hides lower severity duplicate route explanation risks', () => {
|
||||
const advice = buildAiAdviceViewModel({
|
||||
riskCards: [
|
||||
{
|
||||
id: 'route-high',
|
||||
tone: 'high',
|
||||
label: '高风险',
|
||||
title: '多城市行程待说明',
|
||||
risk: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。',
|
||||
itemIds: ['train-transfer', 'train-transfer-return']
|
||||
},
|
||||
{
|
||||
id: 'route-medium',
|
||||
tone: 'medium',
|
||||
label: '中风险',
|
||||
title: '多城市行程缺少说明中风险',
|
||||
risk: '本次报销识别到多城市行程(上海、武汉、深圳),但事由中未说明中转、多地拜访或改签原因。',
|
||||
itemIds: ['train-transfer', 'train-transfer-return']
|
||||
},
|
||||
{
|
||||
id: 'hotel-high',
|
||||
tone: 'high',
|
||||
label: '高风险',
|
||||
title: '住宿金额超出报销标准',
|
||||
risk: '住宿金额超出当前职级报销标准。',
|
||||
itemIds: ['hotel-item']
|
||||
}
|
||||
]
|
||||
})
|
||||
const riskSection = advice.sections.find((section) => section.kind === 'risk')
|
||||
|
||||
assert.deepEqual(advice.riskCards.map((item) => item.id), ['route-high', 'hotel-high'])
|
||||
assert.deepEqual(riskSection.items.map((item) => item.id), ['route-high', 'hotel-high'])
|
||||
assert.equal(riskSection.totalCount, 2)
|
||||
})
|
||||
|
||||
test('AI advice view model omits empty sections', () => {
|
||||
const readyAdvice = buildAiAdviceViewModel({
|
||||
completionItems: [],
|
||||
@@ -640,6 +718,84 @@ test('route-level risk cards keep related item ids for every affected expense ro
|
||||
assert.match(detailViewScript, /cardItemIds\.includes\(itemId\)/)
|
||||
})
|
||||
|
||||
test('claim risk cards expose related expense explanations to reviewers', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'hotel-row',
|
||||
name: '住宿票',
|
||||
desc: '上海喜来登酒店',
|
||||
itemNote: '时间紧,没有合适的酒店,只能住宿超过金额的酒店。'
|
||||
},
|
||||
{
|
||||
id: 'route-extra-out',
|
||||
name: '火车票',
|
||||
desc: '上海-深圳',
|
||||
itemNote: '中间去深圳,公司要求。'
|
||||
},
|
||||
{
|
||||
id: 'route-extra-back',
|
||||
name: '火车票',
|
||||
desc: '深圳-上海',
|
||||
itemNote: '中间去深圳,公司要求。'
|
||||
}
|
||||
],
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
severity: 'high',
|
||||
label: '多城市行程待说明',
|
||||
message: '检测到本次差旅涉及 深圳 多个目的地,但当前报销事由未说明中转、多地拜访或改签原因。',
|
||||
item_ids: ['route-extra-out', 'route-extra-back']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.deepEqual(riskCards[0].itemIds, ['route-extra-out', 'route-extra-back'])
|
||||
assert.match(riskCards[0].risk, /用户已在相关费用明细补充异常说明/)
|
||||
assert.doesNotMatch(riskCards[0].risk, /未说明/)
|
||||
assert.match(riskCards[0].suggestion, /用户已在费用明细补充异常说明/)
|
||||
assert.match(riskCards[0].suggestion, /上海-深圳:中间去深圳,公司要求/)
|
||||
assert.match(riskCards[0].relatedExplanationSummary, /深圳-上海:中间去深圳,公司要求/)
|
||||
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('用户已补充异常说明')))
|
||||
})
|
||||
|
||||
test('claim risk cards infer hotel explanations when risk flag has no item ids', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'hotel-row',
|
||||
name: '住宿票',
|
||||
desc: '上海喜来登酒店',
|
||||
itemType: 'hotel_ticket',
|
||||
itemNote: '时间紧,没有合适的酒店,只能住宿超过金额的酒店。'
|
||||
},
|
||||
{
|
||||
id: 'route-row',
|
||||
name: '火车票',
|
||||
desc: '上海-深圳',
|
||||
itemType: 'train_ticket',
|
||||
itemNote: '中间去深圳,公司要求。'
|
||||
}
|
||||
],
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
severity: 'high',
|
||||
label: '住宿金额超出报销标准',
|
||||
message: '住宿标准:P5在上海的住宿标准为 250.00 元/晚,票据识别金额 1086.00 元 / 3 晚,约 362.00 元/晚,超出 112.00 元/晚。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.deepEqual(riskCards[0].itemIds, ['hotel-row'])
|
||||
assert.match(riskCards[0].relatedExplanationSummary, /上海喜来登酒店:时间紧,没有合适的酒店/)
|
||||
assert.doesNotMatch(riskCards[0].relatedExplanationSummary, /上海-深圳/)
|
||||
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('用户已补充异常说明')))
|
||||
})
|
||||
|
||||
test('legacy route-level risk cards infer affected travel rows when backend has no item ids', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
@@ -853,6 +1009,45 @@ test('ticket item types and system allowance row are visible but read only', ()
|
||||
assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
|
||||
})
|
||||
|
||||
test('expense item rebuild hides empty placeholders but keeps generated allowance row', () => {
|
||||
const items = rebuildExpenseItems(
|
||||
[
|
||||
{
|
||||
id: 'hotel-uploaded',
|
||||
itemType: 'hotel_ticket',
|
||||
itemDate: '2026-02-20',
|
||||
itemReason: '上海喜来登酒店',
|
||||
itemLocation: '上海',
|
||||
itemAmount: 1086,
|
||||
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
|
||||
},
|
||||
{
|
||||
id: 'empty-travel-placeholder',
|
||||
itemType: 'travel',
|
||||
itemDate: '2026-02-23',
|
||||
itemReason: '',
|
||||
itemLocation: '',
|
||||
itemAmount: 0,
|
||||
invoiceId: ''
|
||||
},
|
||||
{
|
||||
id: 'allowance',
|
||||
itemType: 'travel_allowance',
|
||||
itemDate: '2026-02-23',
|
||||
itemReason: '系统自动计算出差补贴:上海,4天,100.00元/天',
|
||||
itemLocation: '直辖市/特区',
|
||||
itemAmount: 400,
|
||||
invoiceId: ''
|
||||
}
|
||||
],
|
||||
{ typeCode: 'travel', detailVariant: 'travel' }
|
||||
)
|
||||
|
||||
assert.deepEqual(items.map((item) => item.id), ['hotel-uploaded', 'allowance'])
|
||||
assert.equal(items[1].isSystemGenerated, true)
|
||||
assert.equal(items[1].attachmentStatus, '无需附件')
|
||||
})
|
||||
|
||||
test('travel item date caption distinguishes departure return and trip events', () => {
|
||||
assert.match(detailViewTemplate, /<span>\{\{ item\.dayLabel \}\}<\/span>/)
|
||||
assert.match(detailViewScript, /const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket'\]\)/)
|
||||
@@ -1114,6 +1309,26 @@ test('standard adjustment resolves submitter risk prompt only after accepted whi
|
||||
)
|
||||
})
|
||||
|
||||
test('multi item risk is not missing explanation when every related row has note', () => {
|
||||
const card = {
|
||||
id: 'risk-multi-city',
|
||||
itemIds: ['route-extra-out', 'route-extra-back'],
|
||||
tone: 'high',
|
||||
risk: '多城市行程待说明。'
|
||||
}
|
||||
const explainedItems = [
|
||||
{ id: 'route-extra-out', itemNote: '中间去深圳,项目现场要求。' },
|
||||
{ id: 'route-extra-back', itemNote: '从深圳返回上海继续支撑部署。' }
|
||||
]
|
||||
const partlyMissingItems = [
|
||||
{ id: 'route-extra-out', itemNote: '中间去深圳,项目现场要求。' },
|
||||
{ id: 'route-extra-back', itemNote: '' }
|
||||
]
|
||||
|
||||
assert.equal(isRiskCardMissingExpenseNote(card, explainedItems), false)
|
||||
assert.equal(isRiskCardMissingExpenseNote(card, partlyMissingItems), true)
|
||||
})
|
||||
|
||||
test('expense item upload remains limited to one receipt per detail row', () => {
|
||||
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
|
||||
assert.doesNotMatch(
|
||||
@@ -1376,6 +1591,100 @@ test('draft submit validation uses expense detail date and amount when claim sum
|
||||
assert.ok(!issues.some((issue) => issue.includes('报销事由未完善')))
|
||||
})
|
||||
|
||||
test('draft submit validation does not hard block uploaded receipt rows with OCR gaps', () => {
|
||||
const issues = buildDraftBlockingIssues(
|
||||
{
|
||||
profileName: '张三',
|
||||
typeLabel: '住宿费',
|
||||
typeCode: 'hotel',
|
||||
reason: '上海出差住宿',
|
||||
location: '上海',
|
||||
occurredDisplay: '2026-06-01',
|
||||
amountValue: 1086
|
||||
},
|
||||
[
|
||||
buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'hotel-uploaded',
|
||||
itemType: 'hotel_ticket',
|
||||
itemReason: '',
|
||||
itemDate: '',
|
||||
itemAmount: 0,
|
||||
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
|
||||
},
|
||||
0,
|
||||
{ typeCode: 'hotel', detailVariant: 'travel' }
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert.ok(!issues.some((issue) => issue.includes('缺少日期')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('缺少说明')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('缺少金额')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('缺少票据标识')))
|
||||
})
|
||||
|
||||
test('draft submit validation ignores trailing placeholder detail rows', () => {
|
||||
const issues = buildDraftBlockingIssues(
|
||||
{
|
||||
profileName: '张三',
|
||||
typeLabel: '差旅费',
|
||||
typeCode: 'travel',
|
||||
reason: '上海出差',
|
||||
location: '上海',
|
||||
occurredDisplay: '2026-02-20 至 2026-02-23',
|
||||
amountValue: 1086
|
||||
},
|
||||
[
|
||||
buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'hotel-uploaded',
|
||||
itemType: 'hotel_ticket',
|
||||
itemReason: '上海喜来登酒店',
|
||||
itemLocation: '上海',
|
||||
itemDate: '2026-02-20',
|
||||
itemAmount: 1086,
|
||||
invoiceId: 'claim-1/hotel-uploaded/hotel-invoice.png'
|
||||
},
|
||||
0,
|
||||
{ typeCode: 'travel', detailVariant: 'travel' }
|
||||
),
|
||||
buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'placeholder-6',
|
||||
itemType: 'hotel_ticket',
|
||||
itemDate: '2026-02-23',
|
||||
itemReason: '',
|
||||
itemLocation: '',
|
||||
itemAmount: 0,
|
||||
invoiceId: ''
|
||||
},
|
||||
1,
|
||||
{ typeCode: 'travel', detailVariant: 'travel' }
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少说明')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少地点')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少金额')))
|
||||
assert.ok(!issues.some((issue) => issue.includes('费用明细第 2 条缺少票据标识')))
|
||||
})
|
||||
|
||||
test('draft submit validation does not require receipt fields for generated allowance rows', () => {
|
||||
const issues = buildExpenseDraftIssues({
|
||||
id: 'allowance',
|
||||
itemType: 'travel_allowance',
|
||||
itemDate: '2026-02-23',
|
||||
itemReason: '',
|
||||
itemLocation: '',
|
||||
itemAmount: 0,
|
||||
invoiceId: ''
|
||||
})
|
||||
|
||||
assert.deepEqual(issues, [])
|
||||
})
|
||||
|
||||
test('returned application submit validation does not require expense detail rows', () => {
|
||||
const issues = buildDraftBlockingIssues(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user