feat: 新增预算费控模型与报销审批流引擎

后端新增预算费控服务和报销单审批流模块,引入申请人费用画像
算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常
量和明细同步,更新差旅报销规则电子表格,前端新增预算分析
组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧
边栏和顶栏样式,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-27 17:31:27 +08:00
parent cbb98f4469
commit d4d5d40569
75 changed files with 5393 additions and 686 deletions

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims,
canAccessAppView,
canDeleteArchivedExpenseClaims,
@@ -22,6 +23,24 @@ test('direct approvers can return claims without receiving delete permissions',
assert.equal(canReturnExpenseClaims(approverUser), true)
assert.equal(canApproveLeaderExpenseClaims(managerUser), true)
assert.equal(canApproveLeaderExpenseClaims(approverUser), true)
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['budget_monitor'], grade: 'P6' }), false)
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['budget_monitor'], grade: 'P8' }), true)
assert.equal(
canApproveBudgetExpenseApplications(
{ roleCodes: ['budget_monitor'], grade: 'P8', departmentName: '交付部' },
{ departmentName: '交付部' }
),
true
)
assert.equal(
canApproveBudgetExpenseApplications(
{ roleCodes: ['budget_monitor'], grade: 'P8', departmentName: '财务部' },
{ departmentName: '交付部' }
),
false
)
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: [], grade: 'P8' }), false)
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'] }), true)
assert.equal(canManageExpenseClaims(managerUser), false)
assert.equal(canManageExpenseClaims(approverUser), false)
})
@@ -81,6 +100,37 @@ test('finance approval inbox only processes finance-stage requests', () => {
)
})
test('budget approval inbox only processes budget-stage requests for budget monitor or senior finance roles', () => {
const budgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '赵预算', departmentName: '交付部' }
const otherDepartmentBudgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '王预算', departmentName: '财务部' }
const seniorFinanceUser = { roleCodes: ['executive'], grade: 'P7', name: '高级财务' }
const p8WithoutBudgetRole = { roleCodes: ['manager'], grade: 'P8', name: '高职级经理' }
assert.equal(
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, budgetUser),
true
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, seniorFinanceUser),
true
)
assert.equal(
canProcessApprovalRequest(
{ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' },
otherDepartmentBudgetUser
),
false
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三' }, p8WithoutBudgetRole),
false
)
assert.equal(
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, budgetUser),
false
)
})
test('users with both finance and manager roles can process both relevant stages', () => {
const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' }

View File

@@ -57,7 +57,7 @@ test('detail topbar ignores system allowance rows when checking missing tickets'
assert.equal(hasMissingAttachment(request), false)
assert.equal(hasPendingInfo(request), false)
assert.deepEqual(alerts, ['直属领导审批'])
assert.deepEqual(alerts, ['SLA 催单次数 0'])
})
test('detail topbar still flags real manual rows without required ticket info', () => {
@@ -96,7 +96,7 @@ test('detail topbar still flags real manual rows without required ticket info',
assert.equal(hasMissingAttachment(request), true)
assert.equal(hasPendingInfo(request), true)
assert.deepEqual(alerts, ['待提交', '缺少票据', '待补信息'])
assert.deepEqual(alerts, ['SLA 催单次数 0', '缺少票据', '待补信息'])
})
test('application detail topbar does not ask for receipt attachments', () => {
@@ -122,5 +122,29 @@ test('application detail topbar does not ask for receipt attachments', () => {
assert.equal(hasMissingAttachment(request), false)
assert.equal(alerts.includes('缺少票据'), false)
assert.deepEqual(alerts, ['直属领导审批'])
assert.deepEqual(alerts, ['SLA 催单次数 0'])
})
test('detail topbar shows SLA reminder count from direct fields and reminder events', () => {
const directAlerts = buildDetailAlerts({
node: '直属领导审批',
approvalKey: 'in_progress',
slaReminderCount: 2,
expenseItems: []
})
const eventAlerts = buildDetailAlerts({
node: '直属领导审批',
approvalKey: 'in_progress',
riskFlags: [
{ source: 'sla_reminder', message: '下属已催单' },
{ event_type: 'urge', message: '再次催单' }
],
expenseItems: []
})
assert.equal(directAlerts[0].label, 'SLA 催单次数 2')
assert.equal(directAlerts[0].tone, 'warning')
assert.equal(directAlerts[0].icon, 'mdi mdi-bell-ring-outline')
assert.equal(eventAlerts[0].label, 'SLA 催单次数 2')
})

View File

@@ -85,15 +85,20 @@ test('documents center list shows created time and conditional stay time columns
assert.match(documentsCenterView, /import \{[\s\S]*formatDocumentListTime[\s\S]*resolveDocumentStayTimeDisplay[\s\S]*\} from '..\/utils\/documentCenterTime\.js'/)
assert.match(documentsCenterView, /<col class="col-created">/)
assert.match(documentsCenterView, /<col v-if="showStayTimeColumn" class="col-stay">/)
assert.match(documentsCenterView, /<col class="col-initiator">/)
assert.match(documentsCenterView, /<th>单号<\/th>[\s\S]*<th>创建时间<\/th>[\s\S]*<th v-if="showStayTimeColumn">停留时间<\/th>/)
assert.match(documentsCenterView, /<th>费用场景<\/th>[\s\S]*<th>发起人<\/th>[\s\S]*<th>事项<\/th>/)
assert.match(documentsCenterView, /<td>\{\{ row\.createdAtDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td>\{\{ row\.initiatorName \}\}<\/td>/)
assert.match(
documentsCenterView,
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
)
assert.match(documentsCenterView, /createdAtDisplay: formatDocumentListTime\(createdAtSource\)/)
assert.match(documentsCenterView, /stayTimeDisplay: resolveDocumentStayTimeDisplay\(normalized\)/)
assert.match(documentsCenterView, /initiatorName,/)
assert.match(documentsCenterView, /row\.initiatorName/)
})
test('documents center action buttons are scoped to application and reimbursement tabs', () => {
@@ -225,9 +230,10 @@ test('documents center status dropdown uses compact filter styling', () => {
assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/)
assert.match(documentsCenterStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentsCenterStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/)
assert.match(documentsCenterStyles, /min-width:\s*1320px;/)
assert.match(documentsCenterStyles, /min-width:\s*1420px;/)
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/)
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentsCenterStyles, /\.document-status-filter\s*\{[\s\S]*min-height:\s*38px;/)
assert.match(documentsCenterStyles, /\.status-filter-trigger\s*\{[\s\S]*min-width:\s*154px;/)

View File

@@ -5,10 +5,12 @@ 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 BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\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 WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \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', () => {
@@ -41,7 +43,7 @@ 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]
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED]
)
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
@@ -50,6 +52,47 @@ test('application claims are mapped as application documents', () => {
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
})
test('application claims wait for department P8 budget monitor after leader approval', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-budget',
claim_no: 'AP-20260525103145-BUDGET',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: '支撑国网服务器上线部署',
location: '上海',
amount: 12000,
invoice_count: 0,
occurred_at: '2026-05-25T00:00:00.000Z',
submitted_at: '2026-05-25T02:00:00.000Z',
created_at: '2026-05-25T01:30:00.000Z',
updated_at: '2026-05-25T03:00:00.000Z',
status: 'submitted',
approval_stage: BUDGET_MANAGER_APPROVAL,
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'Leader Li',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: BUDGET_MANAGER_APPROVAL,
next_approver_name: '赵预算',
next_approver_grade: 'P8',
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_ZHAO_APPROVAL, APPROVAL_COMPLETED]
)
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_ZHAO_APPROVAL)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
})
test('returned application claims include leader return node and supplement status', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-returned',
@@ -86,7 +129,7 @@ test('returned application claims include leader return node and supplement stat
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, RETURNED, WAIT_SUBMIT]
[CREATE_APPLICATION, DIRECT_MANAGER_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/)
@@ -96,7 +139,7 @@ test('returned application claims include leader return node and supplement stat
assert.equal(request.progressSteps.some((step) => step.label === APPROVAL_COMPLETED), false)
})
test('approved application claims complete after direct manager approval only', () => {
test('approved application claims complete after budget approval', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-approved',
claim_no: 'AP-20260525113045-HGFEDCBA',
@@ -120,6 +163,16 @@ test('approved application claims complete after direct manager approval only',
event_type: 'expense_application_approval',
operator: '李经理',
previous_approval_stage: '直属领导审批',
next_approval_stage: '预算管理者审批',
next_approver_name: '赵预算',
next_approver_grade: 'P8',
created_at: '2026-05-25T03:00:00.000Z'
},
{
source: 'budget_approval',
event_type: 'expense_application_budget_approval',
operator: '赵预算',
previous_approval_stage: '预算管理者审批',
next_approval_stage: '审批完成',
created_at: '2026-05-25T03:00:00.000Z'
}
@@ -131,10 +184,11 @@ test('approved application claims complete after direct manager approval only',
assert.equal(request.workflowNode, '审批完成')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
['创建申请', '直属领导审批', '审批完成']
['创建申请', '直属领导审批', '预算管理者审批', '审批完成']
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.progressSteps.find((step) => step.label === '直属领导审批')?.time, '李经理通过')
assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.time, '赵预算通过')
})
test('progress steps show approval operator time and current stay duration', () => {

View File

@@ -19,6 +19,10 @@ const approvalDialog = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestApprovalDialog.vue', import.meta.url)),
'utf8'
)
const budgetAnalysisComponent = readFileSync(
fileURLToPath(new URL('../src/components/travel/TravelRequestBudgetAnalysis.vue', import.meta.url)),
'utf8'
)
const reimbursementService = readFileSync(
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
'utf8'
@@ -53,18 +57,23 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
assert.match(detailScript, /const canApproveRequest = computed/)
assert.match(detailScript, /canApproveLeaderExpenseClaims/)
assert.match(detailScript, /canApproveBudgetExpenseApplications/)
assert.match(detailScript, /isCurrentDirectManagerForRequest/)
assert.match(detailScript, /isCurrentRequestApplicant/)
assert.match(detailScript, /isFinanceApprovalStage/)
assert.match(detailScript, /const isBudgetApprovalStage = computed/)
assert.match(detailScript, /const showBudgetAnalysis = computed/)
assert.match(detailScript, /const isCurrentApplicant = computed/)
assert.match(detailScript, /const isCurrentDirectManagerApprover = computed/)
assert.match(detailScript, /const canProcessFinanceApprovalStage = computed/)
assert.match(detailScript, /const canProcessBudgetApprovalStage = computed/)
assert.match(detailScript, /approvalOpinionTitle/)
assert.match(detailScript, /approvalConfirmDescription/)
assert.match(detailScript, /approvalNextStage/)
assert.doesNotMatch(detailScript, /approvalNextStage/)
assert.doesNotMatch(detailScript, /showApplicationLeaderOpinionInput/)
assert.doesNotMatch(detailScript, /showLeaderApprovalPanel/)
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => isDirectManagerApprovalStage\.value\)/)
assert.match(detailScript, /const requiresApprovalOpinion = computed\(\(\) => false\)/)
assert.match(detailScript, /approvalOpinionTitle = computed\(\(\) => \(isFinanceApprovalStage\.value \? '财务意见' : '附加意见'\)\)/)
assert.match(detailScript, /buildLeaderApprovalEvents/)
assert.match(detailScript, /buildLeaderApprovalInfo/)
assert.match(detailScript, /const leaderApprovalEvents = computed/)
@@ -76,11 +85,13 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
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.match(detailScript, /canProcessBudgetApprovalStage\.value/)
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
assert.match(detailScript, /approveActionLabel/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/)
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
assert.match(detailScript, /流转至预算管理者审批/)
assert.doesNotMatch(detailTemplate, /v-if="showLeaderApprovalPanel"/)
assert.doesNotMatch(detailTemplate, /showApplicationLeaderOpinionInput/)
@@ -96,6 +107,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.doesNotMatch(detailTemplate, /leaderApprovalReadonlyText/)
assert.doesNotMatch(detailTemplate, /\u5f85\u76f4\u5c5e\u9886\u5bfc\u586b\u5199\u5ba1\u6279\u610f\u89c1/)
assert.match(detailTemplate, /领导意见/)
assert.match(detailTemplate, /<TravelRequestBudgetAnalysis[\s\S]*v-if="showBudgetAnalysis"[\s\S]*:claim-id="request\.claimId"/)
assert.match(approvalDialog, /\{\{ opinionTitle \}\}/)
assert.doesNotMatch(detailTemplate, /v-model="leaderOpinion"/)
assert.match(detailTemplate, /@click="handleApproveRequest"/)
@@ -105,7 +117,10 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
assert.match(detailTemplate, /:confirm-text="approveConfirmText"/)
assert.match(detailTemplate, /:busy-text="approveBusyText"/)
assert.match(detailTemplate, /:next-stage="approvalNextStage"/)
assert.doesNotMatch(detailTemplate, /:next-stage="approvalNextStage"/)
assert.doesNotMatch(approvalDialog, /submit-confirm-summary/)
assert.doesNotMatch(approvalDialog, /单据编号/)
assert.doesNotMatch(approvalDialog, /当前节点/)
assert.match(detailTemplate, /v-model:opinion="leaderOpinion"/)
assert.match(detailTemplate, /:opinion-placeholder="approvalOpinionPlaceholder"/)
assert.match(detailTemplate, /:opinion-hint="approvalOpinionHint"/)
@@ -119,8 +134,8 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
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.doesNotMatch(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
assert.doesNotMatch(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
assert.match(approvalDialog, /<textarea/)
assert.match(approvalDialog, /update:opinion/)
@@ -141,4 +156,11 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
assert.match(reimbursementService, /export function approveExpenseClaim\(claimId, payload = \{\}\)/)
assert.match(reimbursementService, /\/approve/)
assert.match(reimbursementService, /export function fetchExpenseClaimBudgetAnalysis/)
assert.match(reimbursementService, /\/budget-analysis/)
assert.match(budgetAnalysisComponent, /预算分析/)
assert.match(budgetAnalysisComponent, /当前预算额度/)
assert.match(budgetAnalysisComponent, /此次费用占预算/)
assert.match(budgetAnalysisComponent, /综合评分/)
assert.match(budgetAnalysisComponent, /fetchExpenseClaimBudgetAnalysis/)
})

View File

@@ -684,9 +684,12 @@ test('return reason dialog is wired into approval and detail return actions', ()
assert.match(returnReasonDialog, /application_budget_basis_missing/)
assert.match(returnReasonDialog, /application_policy_mismatch/)
assert.match(returnReasonDialog, /application_attachment_needed/)
assert.match(returnReasonDialog, /application_other/)
assert.match(returnReasonDialog, /退单选项/)
assert.match(returnReasonDialog, /selectionError/)
assert.match(returnReasonDialog, /selectedCodes\.value\.length === 0/)
assert.match(returnReasonDialog, /selectedApplicationCode/)
assert.match(returnReasonDialog, /application \? 'radio' : 'checkbox'/)
assert.match(returnReasonDialog, /selectedReasonCodes\.value\.length === 0/)
assert.match(returnReasonDialog, /lastAutoReason/)
assert.match(returnReasonDialog, /reason_codes/)
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)