feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
21
web/tests/accessControl.test.mjs
Normal file
21
web/tests/accessControl.test.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { canManageExpenseClaims, canReturnExpenseClaims } from '../src/utils/accessControl.js'
|
||||
|
||||
test('direct approvers can return claims without receiving delete permissions', () => {
|
||||
const managerUser = { roleCodes: ['manager'] }
|
||||
const approverUser = { roleCodes: ['approver'] }
|
||||
|
||||
assert.equal(canReturnExpenseClaims(managerUser), true)
|
||||
assert.equal(canReturnExpenseClaims(approverUser), true)
|
||||
assert.equal(canManageExpenseClaims(managerUser), false)
|
||||
assert.equal(canManageExpenseClaims(approverUser), false)
|
||||
})
|
||||
|
||||
test('finance and executives can return and manage claims', () => {
|
||||
assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true)
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), true)
|
||||
assert.equal(canReturnExpenseClaims({ roleCodes: ['executive'] }), true)
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true)
|
||||
})
|
||||
39
web/tests/approval-center-detail-reuse.test.mjs
Normal file
39
web/tests/approval-center-detail-reuse.test.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const approvalTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const approvalScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('approval center reuses reimbursement detail view instead of its old detail shell', () => {
|
||||
assert.match(approvalTemplate, /<TravelRequestDetailView/)
|
||||
assert.match(approvalTemplate, /back-label="返回审批列表"/)
|
||||
assert.match(approvalTemplate, /approval-mode/)
|
||||
assert.doesNotMatch(approvalTemplate, /class="approval-detail"/)
|
||||
assert.doesNotMatch(approvalTemplate, /<ReturnReasonDialog/)
|
||||
assert.doesNotMatch(approvalTemplate, /<ConfirmDialog/)
|
||||
|
||||
assert.match(approvalScript, /import TravelRequestDetailView from '\.\.\/TravelRequestDetailView\.vue'/)
|
||||
assert.match(approvalScript, /fetchApprovalExpenseClaims/)
|
||||
assert.doesNotMatch(approvalScript, /fetchExpenseClaims/)
|
||||
assert.doesNotMatch(approvalScript, /import ConfirmDialog/)
|
||||
assert.doesNotMatch(approvalScript, /import ReturnReasonDialog/)
|
||||
|
||||
assert.match(detailScript, /backLabel:/)
|
||||
assert.match(detailTemplate, /\{\{ backLabel \}\}/)
|
||||
})
|
||||
62
web/tests/employee-management-history.test.mjs
Normal file
62
web/tests/employee-management-history.test.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const employeeViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/EmployeeManagementView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const employeeViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/EmployeeManagementView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const employeeViewStyles = readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../src/assets/styles/views/employee-management-view.css', import.meta.url)
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function extractFormatEmployeeHistoryTime() {
|
||||
const padMatched = employeeViewScript.match(
|
||||
/function padDatePart\(value\) \{[\s\S]*?\n\}\n\n(?:export\s+)?function formatEmployeeHistoryTime/
|
||||
)
|
||||
assert.ok(padMatched, 'padDatePart should be present before history time formatter')
|
||||
|
||||
const matched = employeeViewScript.match(
|
||||
/(?:export\s+)?function formatEmployeeHistoryTime\(value\) \{[\s\S]*?\n\}\n\nfunction resolveOrganizationUnitCode/
|
||||
)
|
||||
assert.ok(matched, 'formatEmployeeHistoryTime should be present before organization helpers')
|
||||
|
||||
const padSource = padMatched[0].replace(
|
||||
/\n\n(?:export\s+)?function formatEmployeeHistoryTime[\s\S]*$/u,
|
||||
''
|
||||
)
|
||||
const source = matched[0].replace(/\n\nfunction resolveOrganizationUnitCode[\s\S]*$/u, '')
|
||||
return new Function(
|
||||
'normalizeText',
|
||||
`${padSource}; ${source}; return formatEmployeeHistoryTime;`
|
||||
)((value) => String(value || '').trim())
|
||||
}
|
||||
|
||||
test('employee history time uses fixed-width date and minute format', () => {
|
||||
const formatEmployeeHistoryTime = extractFormatEmployeeHistoryTime()
|
||||
|
||||
assert.equal(formatEmployeeHistoryTime('2026年5月6日10时4分'), '2026-05-06 10:04')
|
||||
assert.equal(formatEmployeeHistoryTime('2026-05-06T10:04:33+08:00'), '2026-05-06 10:04')
|
||||
assert.equal(formatEmployeeHistoryTime('2026-05-06 10:04'), '2026-05-06 10:04')
|
||||
})
|
||||
|
||||
test('employee history row keeps owner and time in aligned grid columns', () => {
|
||||
assert.match(employeeViewTemplate, /class="history-row-owner"/)
|
||||
assert.match(employeeViewTemplate, /class="history-row-time"/)
|
||||
assert.doesNotMatch(employeeViewTemplate, /class="history-row-meta"/)
|
||||
|
||||
assert.match(employeeViewStyles, /\.history-row\s*\{[^}]*display:\s*grid/s)
|
||||
assert.match(
|
||||
employeeViewStyles,
|
||||
/\.history-row\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)\s*128px\s*112px/s
|
||||
)
|
||||
assert.match(employeeViewStyles, /\.history-row-time\s*\{[^}]*font-variant-numeric:\s*tabular-nums/s)
|
||||
})
|
||||
17
web/tests/personal-workbench-assistant.test.mjs
Normal file
17
web/tests/personal-workbench-assistant.test.mjs
Normal file
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const workbench = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('workbench assistant greets the current employee without the old helper tag', () => {
|
||||
assert.doesNotMatch(workbench, /assistant-tag/)
|
||||
assert.doesNotMatch(workbench, /AI 报销助手/)
|
||||
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述费用或上传票据,AI 直接帮你判断怎么报/)
|
||||
assert.match(workbench, /const assistantGreetingName = computed/)
|
||||
assert.match(workbench, /user\.name/)
|
||||
})
|
||||
42
web/tests/reimbursementTextInference.test.mjs
Normal file
42
web/tests/reimbursementTextInference.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildLocalExtractionProgressMessages,
|
||||
buildLocalIntentPreview,
|
||||
inferLocalFlowCandidates,
|
||||
summarizeSemanticIntentDetail
|
||||
} from '../src/utils/reimbursementTextInference.js'
|
||||
|
||||
const ridingFareMessage = '业务发生时间:2026-03-04,送客户去林萃小区办事,请报销乘车费用'
|
||||
|
||||
test('local flow intent preview names transport expense for riding fare text', () => {
|
||||
const candidates = inferLocalFlowCandidates(ridingFareMessage)
|
||||
|
||||
assert.equal(candidates.time, '2026-03-04')
|
||||
assert.equal(candidates.event, '交通出行')
|
||||
assert.equal(candidates.expenseType, '交通费')
|
||||
assert.match(buildLocalIntentPreview(ridingFareMessage), /交通费/)
|
||||
assert.ok(
|
||||
buildLocalExtractionProgressMessages(ridingFareMessage).some(
|
||||
(item) => item.includes('交通出行') && item.includes('交通费')
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
test('semantic intent detail includes recognized expense type', () => {
|
||||
assert.equal(
|
||||
summarizeSemanticIntentDetail({
|
||||
scenario: 'expense',
|
||||
intent: 'draft',
|
||||
entities_json: [
|
||||
{
|
||||
type: 'expense_type',
|
||||
value: '交通',
|
||||
normalized_value: 'transport'
|
||||
}
|
||||
]
|
||||
}),
|
||||
'已识别为报销场景,当前目标是草稿生成,费用类型为交通费'
|
||||
)
|
||||
})
|
||||
90
web/tests/requestProgressSteps.test.mjs
Normal file
90
web/tests/requestProgressSteps.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
||||
|
||||
test('progress steps show approval operator time and current stay duration', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
||||
|
||||
try {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-1',
|
||||
claim_no: 'EXP-202605-001',
|
||||
employee_name: '张三',
|
||||
department_name: '市场部',
|
||||
expense_type: 'transport',
|
||||
reason: '交通报销',
|
||||
location: '上海',
|
||||
amount: 88,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-20T01:00:00.000Z',
|
||||
submitted_at: '2026-05-20T02:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T03:30:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: '财务审批',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
operator: '李经理',
|
||||
previous_approval_stage: '直属领导审批',
|
||||
next_approval_stage: '财务审批',
|
||||
created_at: '2026-05-20T03:30:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
||||
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
||||
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
|
||||
|
||||
assert.equal(leaderStep.time, '李经理通过')
|
||||
assert.match(leaderStep.detail, /2026-05-20/)
|
||||
assert.match(leaderStep.title, /李经理审批通过/)
|
||||
assert.equal(aiStep.time, 'AI预审通过')
|
||||
assert.match(aiStep.detail, /2026-05-20/)
|
||||
assert.equal(financeStep.current, true)
|
||||
assert.equal(financeStep.time, '停留 1小时30分钟')
|
||||
} finally {
|
||||
Date.now = originalNow
|
||||
}
|
||||
})
|
||||
|
||||
test('current direct manager step shows how long the claim has stayed there', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime()
|
||||
|
||||
try {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-2',
|
||||
claim_no: 'EXP-202605-002',
|
||||
employee_name: '王五',
|
||||
department_name: '市场部',
|
||||
expense_type: 'office',
|
||||
reason: '办公用品',
|
||||
location: '上海',
|
||||
amount: 128,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-20T01:00:00.000Z',
|
||||
submitted_at: '2026-05-20T02:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T02:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: '直属领导审批',
|
||||
risk_flags_json: [],
|
||||
items: []
|
||||
})
|
||||
|
||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
||||
const submitStep = request.progressSteps.find((step) => step.label === '待提交')
|
||||
|
||||
assert.equal(submitStep.time, '王五提交')
|
||||
assert.match(submitStep.detail, /2026-05-20/)
|
||||
assert.equal(leaderStep.current, true)
|
||||
assert.equal(leaderStep.time, '停留 3小时15分钟')
|
||||
} finally {
|
||||
Date.now = originalNow
|
||||
}
|
||||
})
|
||||
33
web/tests/requestViewModel.test.mjs
Normal file
33
web/tests/requestViewModel.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { normalizeRequestForUi } from '../src/utils/requestViewModel.js'
|
||||
|
||||
test('normalizes backend approval_stage for in-progress claim details', () => {
|
||||
const request = normalizeRequestForUi({
|
||||
id: 'EXP-202605-001',
|
||||
claim_id: 'claim-1',
|
||||
status: 'submitted',
|
||||
approval_stage: '直属领导审批',
|
||||
expense_type: 'transport',
|
||||
amount: 88
|
||||
})
|
||||
|
||||
assert.equal(request.approvalKey, 'in_progress')
|
||||
assert.equal(request.node, '直属领导审批')
|
||||
})
|
||||
|
||||
test('normalizes returned backend claims as editable pending submission', () => {
|
||||
const request = normalizeRequestForUi({
|
||||
id: 'EXP-202605-002',
|
||||
claim_id: 'claim-2',
|
||||
status: 'returned',
|
||||
approval_stage: '待提交',
|
||||
expense_type: 'transport',
|
||||
amount: 66
|
||||
})
|
||||
|
||||
assert.equal(request.approvalKey, 'supplement')
|
||||
assert.equal(request.approvalStatus, '待提交')
|
||||
assert.equal(request.node, '待提交')
|
||||
})
|
||||
37
web/tests/travel-reimbursement-review-drawer-switch.test.mjs
Normal file
37
web/tests/travel-reimbursement-review-drawer-switch.test.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||||
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewDocumentDrawerAvailable"[\s\S]*title="单据识别"/)
|
||||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewRiskDrawerAvailable"[\s\S]*title="显示风险"/)
|
||||
assert.match(createViewTemplate, /title="调用流程"/)
|
||||
|
||||
assert.ok(
|
||||
createViewTemplate.indexOf('title="报销识别核对"') < createViewTemplate.indexOf('title="单据识别"'),
|
||||
'default review button should be placed before the document recognition button'
|
||||
)
|
||||
})
|
||||
|
||||
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
|
||||
assert.match(createViewScript, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
|
||||
assert.match(createViewScript, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
|
||||
assert.match(createViewScript, /function switchToReviewOverviewDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_REVIEW\)/)
|
||||
assert.match(createViewScript, /function toggleReviewDocumentDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_DOCUMENTS\)/)
|
||||
assert.match(createViewScript, /function toggleReviewRiskDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
|
||||
assert.match(createViewScript, /function toggleReviewFlowDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_FLOW\)/)
|
||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_DOCUMENTS\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
})
|
||||
64
web/tests/travel-request-detail-leader-approval.test.mjs
Normal file
64
web/tests/travel-request-detail-leader-approval.test.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const detailTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const signatureIndex = source.indexOf(`function ${name}(`)
|
||||
assert.notEqual(signatureIndex, -1, `${name} should exist`)
|
||||
|
||||
const bodyStart = source.indexOf('{', signatureIndex)
|
||||
assert.notEqual(bodyStart, -1, `${name} should have a body`)
|
||||
|
||||
let depth = 0
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
const char = source[index]
|
||||
if (char === '{') {
|
||||
depth += 1
|
||||
} else if (char === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return source.slice(signatureIndex, index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.fail(`${name} body should be closed`)
|
||||
}
|
||||
|
||||
test('approval-mode detail collects leader opinion and confirms approval before API call', () => {
|
||||
assert.match(detailScript, /approvalMode:/)
|
||||
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
|
||||
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailScript, /const canApproveRequest = computed/)
|
||||
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
|
||||
|
||||
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
|
||||
assert.match(detailTemplate, /领导意见/)
|
||||
assert.match(detailTemplate, /v-model="leaderOpinion"/)
|
||||
assert.match(detailTemplate, /@click="handleApproveRequest"/)
|
||||
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
|
||||
assert.match(detailTemplate, /confirm-text="确认通过"/)
|
||||
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
|
||||
|
||||
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
|
||||
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
|
||||
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
|
||||
assert.match(confirmApproveRequest, /approveExpenseClaim/)
|
||||
|
||||
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
|
||||
assert.match(reimbursementService, /\/approve/)
|
||||
})
|
||||
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, /详情页退回/)
|
||||
})
|
||||
54
web/tests/travel-request-detail-submit-confirm.test.mjs
Normal file
54
web/tests/travel-request-detail-submit-confirm.test.mjs
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
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'
|
||||
)
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const signatureIndex = source.indexOf(`function ${name}(`)
|
||||
assert.notEqual(signatureIndex, -1, `${name} should exist`)
|
||||
|
||||
const bodyStart = source.indexOf('{', signatureIndex)
|
||||
assert.notEqual(bodyStart, -1, `${name} should have a body`)
|
||||
|
||||
let depth = 0
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
const char = source[index]
|
||||
if (char === '{') {
|
||||
depth += 1
|
||||
} else if (char === '}') {
|
||||
depth -= 1
|
||||
if (depth === 0) {
|
||||
return source.slice(signatureIndex, index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.fail(`${name} body should be closed`)
|
||||
}
|
||||
|
||||
test('detail submit opens a confirmation dialog before calling submit API', () => {
|
||||
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*confirm-text="确认提交"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
|
||||
assert.match(detailViewTemplate, /cancel-text="返回核对"/)
|
||||
assert.match(detailViewTemplate, /@click="handleSubmit"/)
|
||||
|
||||
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = false/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen,/)
|
||||
assert.match(detailViewScript, /closeSubmitConfirmDialog,/)
|
||||
assert.match(detailViewScript, /confirmSubmitRequest,/)
|
||||
|
||||
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
|
||||
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
|
||||
assert.doesNotMatch(handleSubmit, /submitExpenseClaim/)
|
||||
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
|
||||
})
|
||||
Reference in New Issue
Block a user