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

@@ -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/)