feat: 完善审批退回流程与报销申请关联

后端优化报销单访问策略和常量定义,增强退回原因和审批状态
流转,前端完善退回对话框和审批交互组件,新增报销申请关联
模型,优化文档中心行数据和审批收件箱工具函数,增强引导
流程和会话模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-27 14:35:17 +08:00
parent 7d32eae74e
commit cbb98f4469
30 changed files with 1794 additions and 250 deletions

View File

@@ -6,6 +6,8 @@ import {
canAccessAppView,
canDeleteArchivedExpenseClaims,
canEditBudgetCenter,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
canManageExpenseClaims,
canReturnExpenseClaims,
canSwitchBudgetDepartments
@@ -87,7 +89,33 @@ test('users with both finance and manager roles can process both relevant stages
true
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeManagerUser),
canProcessApprovalRequest(
{ workflowNode: '直属领导审批', person: '张三', managerName: '李经理' },
financeManagerUser
),
true
)
assert.equal(
canProcessApprovalRequest(
{ workflowNode: '直属领导审批', person: '李经理', managerName: '王总' },
financeManagerUser
),
false
)
assert.equal(
canProcessApprovalRequest(
{ workflowNode: '直属领导审批', person: '张三', managerName: '王总' },
financeManagerUser
),
false
)
})
test('direct-manager approval helpers only match claims pushed to the current user', () => {
const managerUser = { roleCodes: ['manager'], name: '李经理', username: 'manager@example.com' }
assert.equal(isCurrentRequestApplicant({ person: '李经理', managerName: '王总' }, managerUser), true)
assert.equal(isCurrentDirectManagerForRequest({ person: '李经理', managerName: '王总' }, managerUser), false)
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '李经理' }, managerUser), true)
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '王总' }, managerUser), false)
})

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildLeaderApprovalEvents,
buildLeaderApprovalInfo,
resolveGeneratedDraftClaimNo
} from '../src/utils/applicationApproval.js'
@@ -52,3 +53,56 @@ test('resolveGeneratedDraftClaimNo reads approval response payload', () => {
'EXP-202605-0012'
)
})
test('buildLeaderApprovalEvents returns leader return and approval timeline in event order', () => {
const events = buildLeaderApprovalEvents({
profileManager: 'Fallback Manager',
riskFlags: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'Leader Li',
opinion: 'Approved after supplement.',
approval_event_id: 'approval-1',
created_at: '2026-05-25T11:00:00'
},
{
source: 'manual_return',
event_type: 'expense_application_return',
operator: 'manager@example.com',
operator_name: 'Leader Li',
reason: 'Need clearer budget explanation.',
return_count: 1,
return_event_id: 'return-1',
created_at: '2026-05-25T09:00:00'
}
]
})
assert.deepEqual(events.map((event) => event.id), ['return-1', 'approval-1'])
assert.deepEqual(events.map((event) => event.type), ['returned', 'approved'])
assert.deepEqual(events.map((event) => event.tone), ['danger', 'success'])
assert.equal(events[0].operator, 'Leader Li')
assert.equal(events[0].opinion, 'Need clearer budget explanation.')
assert.equal(events[0].returnCount, 1)
assert.equal(events[0].time, '2026-05-25 09:00')
assert.equal(Object.hasOwn(events[0], 'sortAt'), false)
})
test('buildLeaderApprovalEvents hides empty or unrelated return state', () => {
assert.deepEqual(buildLeaderApprovalEvents({ riskFlags: [] }), [])
assert.deepEqual(
buildLeaderApprovalEvents({
riskFlags: [
{
source: 'manual_return',
event_type: 'expense_claim_return',
return_stage_key: 'finance',
reason: 'Finance return should not render as leader application opinion.',
created_at: '2026-05-25T09:00:00'
}
]
}),
[]
)
})

View File

@@ -3,6 +3,8 @@ import test from 'node:test'
import {
excludeArchivedDocumentRows,
filterApplicationScopeNewRows,
prepareApplicationScopeRows,
isArchivedDocumentRow
} from '../src/utils/documentCenterRows.js'
@@ -48,3 +50,34 @@ test('document center all scope excludes archived rows from merged lists', () =>
assert.deepEqual(rows.map((row) => row.claimId), ['c'])
})
test('application scope does not mark submitted approval application rows as new', () => {
const rows = prepareApplicationScopeRows([
{
claimId: 'draft-application',
documentTypeCode: 'application',
statusGroup: 'draft',
isNewDocument: true
},
{
claimId: 'submitted-application',
documentTypeCode: 'application',
statusGroup: 'in_progress',
isNewDocument: true
},
{
claimId: 'reimbursement',
documentTypeCode: 'reimbursement',
statusGroup: 'in_progress',
isNewDocument: true
}
])
assert.deepEqual(rows.map((row) => row.claimId), ['draft-application', 'submitted-application'])
assert.equal(rows.find((row) => row.claimId === 'draft-application')?.isNewDocument, true)
assert.equal(rows.find((row) => row.claimId === 'submitted-application')?.isNewDocument, false)
assert.deepEqual(
filterApplicationScopeNewRows(rows).map((row) => row.claimId),
['draft-application']
)
})

View File

@@ -49,7 +49,7 @@ test('documents center category tabs map to the intended row sources', () => {
)
assert.match(
documentsCenterView,
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION/
/activeScopeTab\.value === DOCUMENT_SCOPE_APPLICATION[\s\S]*return applicationScopeRows\.value/
)
assert.match(
documentsCenterView,
@@ -122,7 +122,11 @@ test('documents center category tabs render bubble counts for new documents', ()
assert.match(documentsCenterView, /\[DOCUMENT_SCOPE_ALL\]: countNewDocuments\(nonArchivedRows\.value, viewedDocumentKeys\.value\)/)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_APPLICATION\]: countNewDocuments\(nonArchivedRows\.value\.filter\(\(row\) => row\.documentTypeCode === DOCUMENT_TYPE_APPLICATION\), viewedDocumentKeys\.value\)/
/const applicationScopeRows = computed\(\(\) => prepareApplicationScopeRows\(ownedRows\.value\)\)/
)
assert.match(
documentsCenterView,
/\[DOCUMENT_SCOPE_APPLICATION\]: countNewDocuments\(filterApplicationScopeNewRows\(applicationScopeRows\.value\), viewedDocumentKeys\.value\)/
)
assert.match(
documentsCenterView,

View File

@@ -17,6 +17,10 @@ import {
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
import { renderMarkdown } from '../src/utils/markdown.js'
import {
createMessage as createConversationMessage,
hasMeaningfulSessionMessages
} from '../src/views/scripts/travelReimbursementConversationModel.js'
const submitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
@@ -217,6 +221,23 @@ test('application quick start renders a template without model review', () => {
assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/)
})
test('application quick start template counts as deletable session content', () => {
const welcomeMessage = createConversationMessage('assistant', '欢迎语', [], {
isWelcome: true,
welcomeQuickActions: [{ label: '快速发起申请', action: 'start_guided_application' }]
})
const templateMessage = createConversationMessage('assistant', '申请模板', [], {
applicationPreview: buildApplicationTemplatePreview({
name: '测试员工',
departmentName: '财务部',
grade: 'P5'
})
})
assert.equal(hasMeaningfulSessionMessages([welcomeMessage]), false)
assert.equal(hasMeaningfulSessionMessages([welcomeMessage, templateMessage]), true)
})
test('application session shows intent flow, persists preview, and supports inline table edit', () => {
assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/)
assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/)
@@ -228,7 +249,7 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(submitComposerScript, /startFlowStep\('intent'/)
assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/)
assert.match(submitComposerScript, /completeFlowStep\('intent'/)
assert.match(submitComposerScript, /insightPanelCollapsed\.value = true/)
assert.doesNotMatch(submitComposerScript, /insightPanelCollapsed\.value = true/)
assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/)
assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/)
assert.ok(
@@ -239,7 +260,7 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(createViewScript, /const isApplicationSession = computed/)
assert.match(createViewScript, /insightPanelCollapsed,/)
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
assert.match(createViewScript, /flowSteps\.value\.length > 0/)
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
assert.match(createViewScript, /useApplicationPreviewEditor/)
assert.match(createViewScript, /message-bubble-application-preview/)
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
@@ -248,6 +269,8 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
assert.match(conversationModelScript, /applicationPreview: null/)
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
assert.match(conversationModelScript, /\|\| message\.applicationPreview/)
assert.match(createViewScript, /hasMeaningfulSessionMessages\(messages\.value\)/)
assert.match(messageItemTemplate, /class="application-preview-table"/)
assert.match(messageItemTemplate, /class="application-preview-footer application-preview-footer-missing"/)

View File

@@ -3,12 +3,21 @@ import test from 'node:test'
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
const RETURNED = '\u9000\u56de'
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
test('application claims are mapped as application documents', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-1',
claim_no: 'AP-20260525103045-ABCDEFGH',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: '支撑国网服务器上线部署',
location: '上海',
@@ -32,11 +41,59 @@ test('application claims are mapped as application documents', () => {
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
['创建申请', '直属领导审批', '审批完成']
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED]
)
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.current, true)
assert.equal(request.progressSteps.some((step) => step.label === DIRECT_MANAGER_APPROVAL), false)
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.rawLabel, DIRECT_MANAGER_APPROVAL)
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
})
test('returned application claims include leader return node and supplement status', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-returned',
claim_no: 'APP-20260525-RETURNED',
employee_name: 'Applicant Zhang',
department_name: 'Delivery',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: 'Project onsite support',
location: 'Shanghai',
amount: 12000,
invoice_count: 0,
occurred_at: '2026-05-25T00:00:00.000Z',
submitted_at: null,
created_at: '2026-05-25T01:30:00.000Z',
updated_at: '2026-05-25T04:00:00.000Z',
status: 'returned',
approval_stage: WAIT_SUBMIT,
risk_flags_json: [
{
source: 'manual_return',
event_type: 'expense_application_return',
operator: 'Leader Li',
opinion: 'Need clearer budget explanation.',
return_stage_key: 'direct_manager',
next_status: 'returned',
next_approval_stage: WAIT_SUBMIT,
return_count: 2,
created_at: '2026-05-25T04:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, RETURNED, WAIT_SUBMIT]
)
assert.equal(request.progressSteps.find((step) => step.label === RETURNED)?.time, 'Leader Li\u9000\u56de')
assert.match(request.progressSteps.find((step) => step.label === RETURNED)?.detail, /2026-05-25/)
assert.equal(request.progressSteps.find((step) => step.label === WAIT_SUBMIT)?.current, true)
assert.equal(request.secondaryStatusValue, LEADER_RETURNED_STATUS)
assert.equal(request.secondaryStatusTone, 'warning')
assert.equal(request.progressSteps.some((step) => step.label === APPROVAL_COMPLETED), false)
})
test('approved application claims complete after direct manager approval only', () => {

View File

@@ -21,6 +21,7 @@ import {
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION,
GUIDED_ACTION_SELECT_QUERY_MODE,
GUIDED_ACTION_SELECT_QUERY_STATUS,
GUIDED_ACTION_START_REIMBURSEMENT,
@@ -42,10 +43,19 @@ import {
createGuidedStatusQueryState,
isGuidedReimbursementReadyForReview,
normalizeGuidedFlowState,
selectGuidedRequiredApplication,
selectGuidedExpenseType,
selectGuidedQueryMode,
shouldConfirmGuidedInterruption
shouldConfirmGuidedInterruption,
waitForGuidedApplicationSelection
} from '../src/views/scripts/travelReimbursementGuidedFlowModel.js'
import {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates,
requiresApplicationBeforeReimbursement
} from '../src/views/scripts/travelReimbursementApplicationLinkModel.js'
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
resolveAssistantScopeGuard
@@ -71,7 +81,7 @@ const submitComposerScript = readFileSync(
test('assistant session modes expose independent quick actions', () => {
assert.deepEqual(
ASSISTANT_SESSION_MODE_OPTIONS.map((item) => item.label),
['申请助手', '报销助手', '审核助手', '财务知识助手']
['申请助手', '报销助手', '审核助手', '财务知识助手', '预算编制助手']
)
assert.deepEqual(
EXPENSE_WELCOME_QUICK_ACTIONS.map((item) => item.label),
@@ -181,6 +191,82 @@ test('guided reimbursement asks type first and walks travel fields in order', ()
assert.match(submitOptions.rawText, /出差时间\/天数2026-05-20 至 2026-05-23出差 3 天/)
})
test('guided reimbursement requires application selection for travel and entertainment', () => {
assert.equal(requiresApplicationBeforeReimbursement('travel'), true)
assert.equal(requiresApplicationBeforeReimbursement('meal'), true)
assert.equal(requiresApplicationBeforeReimbursement('transport'), false)
const claimsPayload = {
items: [
{
id: 'app-travel',
claim_no: 'AP-202605-001',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '去上海支持项目部署',
location: '上海',
amount: 1800,
status: 'approved',
created_at: '2026-05-20T08:00:00Z'
},
{
id: 'app-meal',
claim_no: 'AP-202605-002',
employee_name: '张小青',
expense_type: 'expense_application',
reason: '客户招待沟通项目',
location: '武汉',
amount: 600,
status: 'submitted',
created_at: '2026-05-21T08:00:00Z'
},
{
id: 'app-draft',
claim_no: 'AP-202605-003',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '草稿出差申请',
status: 'draft'
},
{
id: 'app-other-user',
claim_no: 'AP-202605-004',
employee_name: '李四',
expense_type: 'travel_application',
reason: '其他员工出差申请',
status: 'approved'
}
]
}
const currentUser = { name: '张小青', username: 'xiaoqing.zhang' }
const travelApplications = filterRequiredApplicationCandidates(claimsPayload, 'travel', currentUser)
assert.deepEqual(travelApplications.map((item) => item.claim_no), ['AP-202605-001'])
assert.match(buildRequiredApplicationSelectionText('travel', travelApplications), /需要先关联对应的申请单/)
assert.match(buildRequiredApplicationMissingText('meal'), /不能继续这类报销流程/)
const mealApplications = filterRequiredApplicationCandidates(claimsPayload, 'meal', currentUser)
assert.deepEqual(mealApplications.map((item) => item.claim_no), ['AP-202605-002'])
const actions = buildRequiredApplicationActions(travelApplications, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
assert.equal(actions[0].action_type, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
assert.equal(actions[0].payload.application_claim_no, 'AP-202605-001')
let state = waitForGuidedApplicationSelection(createGuidedReimbursementState(), 'travel', travelApplications)
assert.equal(state.stepKey, 'application_selection')
assert.equal(state.applicationCandidates[0].claim_no, 'AP-202605-001')
state = selectGuidedRequiredApplication(state, actions[0].payload)
assert.equal(state.stepKey, 'reason')
assert.equal(state.values.application_claim_no, 'AP-202605-001')
assert.match(buildGuidedReimbursementSummaryText(state), /关联申请单AP-202605-001/)
const submitOptions = buildGuidedReviewSubmitOptions(state)
assert.equal(submitOptions.extraContext.review_form_values.application_claim_no, 'AP-202605-001')
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
assert.match(submitOptions.rawText, /关联申请单AP-202605-001/)
})
test('guided reimbursement interrupts suspicious questions before expensive flow', () => {
const state = selectGuidedExpenseType(createGuidedReimbursementState(), 'transport')
assert.equal(shouldConfirmGuidedInterruption('送客户去机场', state), false)
@@ -232,7 +318,8 @@ test('guided flow state is serializable and restored through session state', ()
amount: '200',
attachment_names: ['a.pdf']
},
pendingInterruptionText: '查询状态?'
pendingInterruptionText: '查询状态?',
applicationCandidates: []
}
)
@@ -241,7 +328,7 @@ test('guided flow state is serializable and restored through session state', ()
assert.match(sessionStateScript, /guidedFlowState,\s*\n\s*insightPanelCollapsed/)
assert.match(sessionStateScript, /function refreshWelcomeQuickActions/)
assert.match(sessionStateScript, /buildWelcomeQuickActions\(/)
assert.match(sessionStateScript, /ASSISTANT_SESSION_TYPES\.reduce/)
assert.match(sessionStateScript, /resolveAccessibleSessionTypes\(\)\.reduce/)
assert.match(sessionStateScript, /props\.entrySource === 'application' \? SESSION_TYPE_APPLICATION : SESSION_TYPE_EXPENSE/)
assert.match(sessionStateScript, /const canRestorePersistedInitialState =[\s\S]*shouldPersistLocalSnapshot/)
})
@@ -250,6 +337,10 @@ test('guided flow is local until final confirmation or collected query handoff',
assert.doesNotMatch(guidedFlowScript, /runOrchestrator/)
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
assert.match(guidedFlowScript, /fetchExpenseClaims/)
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
assert.match(createViewScript, /handleSceneSelectionApplicationGate/)
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
assert.match(createViewScript, /ASSISTANT_SCOPE_ACTION_SWITCH/)
assert.match(createViewScript, /actionPayload\.carry_text/)

View File

@@ -15,6 +15,10 @@ const detailStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8'
)
const approvalDialog = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)),
'utf8'
)
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
@@ -43,34 +47,57 @@ function extractFunction(source, name) {
assert.fail(`${name} body should be closed`)
}
test('approval-mode detail collects leader opinion and confirms approval before API call', () => {
test('approval-mode detail collects leader opinion inside confirm dialog 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, /canApproveLeaderExpenseClaims/)
assert.match(detailScript, /isCurrentDirectManagerForRequest/)
assert.match(detailScript, /isCurrentRequestApplicant/)
assert.match(detailScript, /isFinanceApprovalStage/)
assert.match(detailScript, /const isCurrentApplicant = computed/)
assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/)
assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/)
assert.match(detailScript, /approvalOpinionTitle/)
assert.match(detailScript, /approvalConfirmDescription/)
assert.match(detailScript, /approvalNextStage/)
assert.match(detailScript, /showApplicationLeaderOpinionInput/)
assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/)
assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/)
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/)
assert.match(detailScript, /buildLeaderApprovalEvents/)
assert.match(detailScript, /buildLeaderApprovalInfo/)
assert.match(detailScript, /const leaderApprovalEvents = computed/)
assert.match(detailScript, /const hasLeaderApprovalEvents = computed/)
assert.match(
detailScript,
/const showApplicationLeaderOpinion = computed\(\(\) => \(\s*isApplicationDocument\.value\s*&& hasLeaderApprovalEvents\.value\s*\)\)/
)
assert.match(detailScript, /isDirectManagerApprovalStage\.value\)[\s\S]*return isCurrentDirectManagerApprover\.value/)
assert.match(detailScript, /isDirectManagerApprovalStage\.value[\s\S]*&& isCurrentDirectManagerApprover\.value/)
assert.match(detailScript, /canProcessFinanceApprovalStage\.value/)
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
assert.match(detailScript, /approveActionLabel/)
assert.match(detailScript, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
assert.match(detailScript, /请先填写领导意见,填写后才能确认审核。/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
assert.doesNotMatch(detailTemplate, /v-if="showLeaderApprovalPanel"/)
assert.doesNotMatch(detailTemplate, /showApplicationLeaderOpinionInput/)
assert.doesNotMatch(detailTemplate, /class="leader-approval-card/)
assert.doesNotMatch(detailTemplate, /class="inline-leader-opinion/)
assert.match(detailTemplate, /v-if="showApplicationLeaderOpinion"/)
assert.match(detailTemplate, /class="application-leader-opinion"/)
assert.match(detailTemplate, /v-if="hasLeaderApprovalEvents"/)
assert.match(detailTemplate, /class="application-leader-opinion-timeline"/)
assert.match(detailTemplate, /v-for="event in leaderApprovalEvents"/)
assert.match(detailTemplate, /class="application-leader-opinion-event"/)
assert.match(detailTemplate, /event\.type === 'returned'/)
assert.doesNotMatch(detailTemplate, /leaderApprovalReadonlyText/)
assert.doesNotMatch(detailTemplate, /\u5f85\u76f4\u5c5e\u9886\u5bfc\u586b\u5199\u5ba1\u6279\u610f\u89c1/)
assert.match(detailTemplate, /领导意见/)
assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
assert.match(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /maxlength="500"\s+:required="requiresApprovalOpinion"/)
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
assert.match(approvalDialog, /\{\{ opinionTitle \}\}/)
assert.doesNotMatch(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /@click="handleApproveRequest"/)
assert.match(detailTemplate, /\{\{ approveBusy \? approveBusyLabel : approveActionLabel \}\}/)
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
@@ -78,18 +105,39 @@ test('approval-mode detail collects leader opinion and confirms approval before
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
assert.match(detailTemplate, /:busy-text="approveBusyText"/)
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
assert.match(detailTemplate, /:next-stage="approvalNextStage"/)
assert.match(detailTemplate, /v-model:opinion="leaderOpinion"/)
assert.match(detailTemplate, /:opinion-placeholder="approvalOpinionPlaceholder"/)
assert.match(detailTemplate, /:opinion-hint="approvalOpinionHint"/)
assert.match(detailTemplate, /:opinion-required="requiresApprovalOpinion"/)
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
assert.match(detailTemplate, /:description="returnDialogDescription"/)
assert.match(detailTemplate, /:application="isApplicationDocument"/)
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
const confirmApproveRequest = extractFunction(detailScript, 'confirmApproveRequest')
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/)
assert.match(confirmApproveRequest, /approveExpenseClaim/)
assert.match(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
assert.match(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
assert.match(approvalDialog, /<textarea/)
assert.match(approvalDialog, /update:opinion/)
assert.match(approvalDialog, /opinionPlaceholder/)
assert.match(approvalDialog, /opinionHint/)
assert.match(approvalDialog, /opinionRequired/)
assert.match(approvalDialog, /\{\{ currentOpinion\.length \}\}\/500/)
assert.match(detailStyles, /\.detail-card-title-with-icon \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
assert.match(detailStyles, /\.detail-card-title-with-icon i \{[\s\S]*font-size: 18px;[\s\S]*line-height: 1;/)
assert.match(detailStyles, /\.application-leader-opinion-head span \{[\s\S]*display: inline-flex;[\s\S]*align-items: center;[\s\S]*gap: 8px;/)
assert.doesNotMatch(detailStyles, /\.leader-approval-card/)
assert.doesNotMatch(detailStyles, /\.inline-leader-opinion/)
assert.match(detailStyles, /\.application-leader-opinion-timeline \{/)
assert.match(detailStyles, /\.application-leader-opinion-event \{/)
assert.match(detailStyles, /\.application-leader-opinion-event\.danger::before \{/)
assert.match(detailStyles, /\.application-leader-opinion-event\.success::before \{/)
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
assert.match(reimbursementService, /\/approve/)

View File

@@ -678,10 +678,21 @@ test('transport ticket items no longer generate business location completion adv
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, /APPLICATION_RETURN_REASON_OPTIONS/)
assert.match(returnReasonDialog, /application_info_incomplete/)
assert.match(returnReasonDialog, /application_business_need_unclear/)
assert.match(returnReasonDialog, /application_budget_basis_missing/)
assert.match(returnReasonDialog, /application_policy_mismatch/)
assert.match(returnReasonDialog, /application_attachment_needed/)
assert.match(returnReasonDialog, /退单选项/)
assert.match(returnReasonDialog, /selectionError/)
assert.match(returnReasonDialog, /selectedCodes\.value\.length === 0/)
assert.match(returnReasonDialog, /lastAutoReason/)
assert.match(returnReasonDialog, /reason_codes/)
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
assert.match(detailViewTemplate, /<ReturnReasonDialog/)
assert.match(detailViewTemplate, /<TravelRequestReturnDialog/)
assert.match(detailViewTemplate, /:application="isApplicationDocument"/)
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)