feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
186
web/tests/travel-request-detail-risk-advice.test.mjs
Normal file
186
web/tests/travel-request-detail-risk-advice.test.mjs
Normal file
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards
|
||||
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||
|
||||
const detailViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const requestsComposableScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const approvalCenterTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const approvalCenterScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const returnReasonDialog = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/ReturnReasonDialog.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const attachmentMeta = {
|
||||
file_name: 'taxi-invoice.pdf',
|
||||
media_type: 'application/pdf',
|
||||
previewable: true,
|
||||
document_info: {
|
||||
document_type: 'taxi_receipt',
|
||||
document_type_label: '出租车/网约车票据',
|
||||
fields: [
|
||||
{ label: '金额', value: '121.54' },
|
||||
{ label: '日期', value: '2026-03-04' }
|
||||
]
|
||||
},
|
||||
requirement_check: {
|
||||
matches: false,
|
||||
message: '附件类型与当前费用项目不匹配。'
|
||||
},
|
||||
analysis: {
|
||||
severity: 'high',
|
||||
label: '高风险',
|
||||
headline: '票据类型不匹配',
|
||||
summary: '交通票据挂在办公费明细下。',
|
||||
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公费'],
|
||||
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
|
||||
}
|
||||
}
|
||||
|
||||
test('attachment insight exposes recognition fields and rule basis', () => {
|
||||
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
|
||||
name: '办公费',
|
||||
itemType: 'office'
|
||||
})
|
||||
|
||||
assert.equal(insight.documentTypeLabel, '出租车/网约车票据')
|
||||
assert.equal(insight.requirementLabel, '不符合当前费用类型')
|
||||
assert.deepEqual(insight.fields, ['金额:121.54', '日期:2026-03-04'])
|
||||
assert.ok(insight.ruleBasis.some((item) => item.includes('附件类型与当前费用项目不匹配')))
|
||||
})
|
||||
|
||||
test('AI advice card splits every attachment risk point with basis and suggestion', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'item-1',
|
||||
name: '办公费',
|
||||
invoiceId: 'taxi-invoice.pdf'
|
||||
}
|
||||
],
|
||||
attachmentMetaByItemId: {
|
||||
'item-1': attachmentMeta
|
||||
}
|
||||
})
|
||||
const advice = buildAiAdviceViewModel({
|
||||
completionItems: [],
|
||||
riskCards
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 2)
|
||||
assert.equal(advice.badge, '优先整改')
|
||||
assert.equal(advice.riskCards.length, 2)
|
||||
assert.ok(advice.riskCards.every((card) => card.ruleBasis.length > 0))
|
||||
assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
|
||||
})
|
||||
|
||||
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'manual_return',
|
||||
severity: 'medium',
|
||||
label: '人工退回',
|
||||
message: '第一次退回:缺少附件。',
|
||||
reason: '缺少附件。',
|
||||
return_count: 1,
|
||||
return_stage: '直属领导审批',
|
||||
risk_points: ['附件缺失或不清晰']
|
||||
},
|
||||
{
|
||||
source: 'manual_return',
|
||||
severity: 'medium',
|
||||
label: '人工退回',
|
||||
message: '第二次退回:超标说明不完整。',
|
||||
reason: '超标说明不完整。',
|
||||
return_count: 2,
|
||||
return_stage: '财务审批',
|
||||
risk_points: ['超出制度标准或缺少超标说明']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.equal(riskCards[0].risk, '第二次退回:超标说明不完整。')
|
||||
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('累计退回 2 次')))
|
||||
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('财务审批')))
|
||||
})
|
||||
|
||||
test('expense attachment actions keep preview as the only recognition entry point', () => {
|
||||
assert.match(detailViewTemplate, /:aria-label="resolveAttachmentPreviewTitle\(item\)"/)
|
||||
assert.match(detailViewScript, /return fileName \? `预览附件:\$\{fileName\}` : '预览附件'/)
|
||||
assert.doesNotMatch(detailViewTemplate, /aria-label="识别附件"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /点击识别按钮/)
|
||||
assert.doesNotMatch(detailViewScript, /recognizeExpenseAttachment/)
|
||||
assert.doesNotMatch(detailViewScript, /recognizingExpenseId/)
|
||||
})
|
||||
|
||||
test('expense detail table omits compact-breaking summary labels', () => {
|
||||
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
|
||||
assert.match(detailViewTemplate, /当前还没有费用明细/)
|
||||
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /expense-total-bar/)
|
||||
assert.doesNotMatch(detailViewTemplate, /合计 \{\{ expenseTotal \}\}/)
|
||||
assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/)
|
||||
assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/)
|
||||
})
|
||||
|
||||
test('expense detail table shows each item filled time from item creation time', () => {
|
||||
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>/)
|
||||
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}/)
|
||||
assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
|
||||
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
|
||||
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
|
||||
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 6 \+ \(isEditableRequest\.value \? 1 : 0\)/)
|
||||
assert.match(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
|
||||
})
|
||||
|
||||
test('expense item upload remains limited to one receipt per detail row', () => {
|
||||
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/)
|
||||
assert.equal(
|
||||
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId"/g) || []).length,
|
||||
2
|
||||
)
|
||||
assert.match(detailViewScript, /const attachments = invoiceId \? \[attachmentName \|\| invoiceId\] : \[\]/)
|
||||
assert.match(detailViewScript, /attachmentStatus: attachments\.length \? '已关联票据' : '未上传'/)
|
||||
assert.match(detailViewScript, /if \(item\?\.invoiceId\) \{[\s\S]*每条费用明细只能关联一张单据/)
|
||||
assert.match(detailViewScript, /const fileCount = fileList\?\.length \|\| 0/)
|
||||
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
|
||||
})
|
||||
|
||||
test('return reason dialog is wired into approval and detail return actions', () => {
|
||||
assert.match(returnReasonDialog, /missing_attachment/)
|
||||
assert.match(returnReasonDialog, /invoice_mismatch/)
|
||||
assert.match(returnReasonDialog, /reason_codes/)
|
||||
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
|
||||
assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
|
||||
assert.match(detailViewTemplate, /<ReturnReasonDialog/)
|
||||
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
|
||||
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
|
||||
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
|
||||
assert.doesNotMatch(detailViewScript, /详情页退回/)
|
||||
})
|
||||
Reference in New Issue
Block a user