test: 同步报销审批流与预算分析测试

- 新增预算审批合并、风险标记去重与占位条目校验用例
- 补充预算分析对当前审核人与财务的可见性断言
- 调整单据删除权限测试以匹配 admin 限制
This commit is contained in:
caoxiaozhu
2026-06-17 14:39:26 +08:00
parent 0fac8b615f
commit 4199feb681
10 changed files with 907 additions and 42 deletions

View File

@@ -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(
{