feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View 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)
})

View 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 \}\}/)
})

View 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)
})

View 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/)
})

View 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'
}
]
}),
'已识别为报销场景,当前目标是草稿生成,费用类型为交通费'
)
})

View 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
}
})

View 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, '待提交')
})

View 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/)
})

View 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/)
})

View 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, /详情页退回/)
})

View 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\)/)
})