后端新增预算费控服务和报销单审批流模块,引入申请人费用画像 算法,优化知识库 RAG 运行时和同步逻辑,完善报销单工作流常 量和明细同步,更新差旅报销规则电子表格,前端新增预算分析 组件和数字员工模型,完善审批对话框和洞察面板交互,优化侧 边栏和顶栏样式,补充单元测试。
485 lines
17 KiB
JavaScript
485 lines
17 KiB
JavaScript
import assert from 'node:assert/strict'
|
|
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 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', () => {
|
|
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: '上海',
|
|
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-25T02:00:00.000Z',
|
|
status: 'submitted',
|
|
approval_stage: '直属领导审批',
|
|
risk_flags_json: [],
|
|
items: []
|
|
})
|
|
|
|
assert.equal(request.documentTypeCode, 'application')
|
|
assert.equal(request.documentTypeLabel, '申请单')
|
|
assert.equal(request.typeLabel, '差旅费用申请')
|
|
assert.equal(request.secondaryStatusLabel, '申请材料')
|
|
assert.equal(request.secondaryStatusValue, '已进入审批流程')
|
|
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
|
assert.deepEqual(
|
|
request.progressSteps.map((step) => step.label),
|
|
[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)
|
|
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('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',
|
|
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, 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/)
|
|
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 budget approval', () => {
|
|
const request = mapExpenseClaimToRequest({
|
|
id: 'claim-application-approved',
|
|
claim_no: 'AP-20260525113045-HGFEDCBA',
|
|
employee_name: '张三',
|
|
department_name: '交付部',
|
|
manager_name: '李经理',
|
|
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: 'approved',
|
|
approval_stage: '审批完成',
|
|
risk_flags_json: [
|
|
{
|
|
source: 'manual_approval',
|
|
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'
|
|
}
|
|
],
|
|
items: []
|
|
})
|
|
|
|
assert.equal(request.documentTypeCode, 'application')
|
|
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', () => {
|
|
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预审')
|
|
const firstStep = request.progressSteps[0]
|
|
|
|
assert.equal(request.riskSummary, '无')
|
|
assert.equal(firstStep.label, '创建单据')
|
|
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('progress steps do not expose approver email when manager name is available', () => {
|
|
const originalNow = Date.now
|
|
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
|
|
|
try {
|
|
const request = mapExpenseClaimToRequest({
|
|
id: 'claim-email-operator',
|
|
claim_no: 'EXP-202605-003',
|
|
employee_name: '张三',
|
|
department_name: '市场部',
|
|
manager_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: 'manager@example.com',
|
|
operator_username: 'manager@example.com',
|
|
previous_approval_stage: '直属领导审批',
|
|
next_approval_stage: '财务审批',
|
|
created_at: '2026-05-20T03:30:00.000Z'
|
|
}
|
|
],
|
|
items: []
|
|
})
|
|
|
|
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
|
|
|
assert.equal(leaderStep.time, '李经理通过')
|
|
assert.ok(!leaderStep.title.includes('manager@example.com'))
|
|
} finally {
|
|
Date.now = originalNow
|
|
}
|
|
})
|
|
|
|
test('travel expense items describe departure return and lodging time below the date', () => {
|
|
const request = mapExpenseClaimToRequest({
|
|
id: 'claim-travel-time-labels',
|
|
claim_no: 'EXP-202605-TRAVEL',
|
|
employee_name: '张三',
|
|
department_name: '市场部',
|
|
expense_type: 'travel',
|
|
reason: '北京客户现场出差',
|
|
location: '北京',
|
|
amount: 1108,
|
|
invoice_count: 3,
|
|
occurred_at: '2026-05-13T01:00:00.000Z',
|
|
created_at: '2026-05-13T01:30:00.000Z',
|
|
updated_at: '2026-05-13T03:30:00.000Z',
|
|
status: 'draft',
|
|
approval_stage: '待提交',
|
|
risk_flags_json: [],
|
|
items: [
|
|
{
|
|
id: 'outbound-train',
|
|
item_type: 'train_ticket',
|
|
item_reason: '广州南-北京南',
|
|
item_location: '北京',
|
|
item_date: '2026-05-13',
|
|
item_amount: 354,
|
|
invoice_id: 'outbound.png'
|
|
},
|
|
{
|
|
id: 'hotel',
|
|
item_type: 'hotel_ticket',
|
|
item_reason: '北京全季酒店',
|
|
item_location: '北京',
|
|
item_date: '2026-05-14',
|
|
item_amount: 100,
|
|
invoice_id: 'hotel.png'
|
|
},
|
|
{
|
|
id: 'return-train',
|
|
item_type: 'train_ticket',
|
|
item_reason: '北京南-广州南',
|
|
item_location: '广州',
|
|
item_date: '2026-05-15',
|
|
item_amount: 354,
|
|
invoice_id: 'return.png'
|
|
},
|
|
{
|
|
id: 'allowance',
|
|
item_type: 'travel_allowance',
|
|
item_reason: '系统自动计算出差补贴',
|
|
item_location: '北京',
|
|
item_date: '2026-05-15',
|
|
item_amount: 300,
|
|
invoice_id: '',
|
|
is_system_generated: true
|
|
}
|
|
]
|
|
})
|
|
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'outbound-train')?.dayLabel, '出发时间')
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'return-train')?.dayLabel, '返回时间')
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'hotel')?.dayLabel, '住宿时间')
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'outbound-train')?.detail, '起始地-目的地')
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'return-train')?.detail, '起始地-目的地')
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'hotel')?.detail, '目的地酒店')
|
|
assert.equal(request.expenseItems.at(-1)?.id, 'allowance')
|
|
assert.equal(request.expenseItems.at(-1)?.dayLabel, '系统自动计算')
|
|
})
|
|
|
|
test('ticket description helper does not show the destination city as detail text', () => {
|
|
const request = mapExpenseClaimToRequest({
|
|
id: 'claim-ticket-detail-helper',
|
|
claim_no: 'EXP-202605-ROUTE',
|
|
employee_name: '张三',
|
|
department_name: '市场部',
|
|
expense_type: 'travel',
|
|
reason: '上海项目出差',
|
|
location: '上海',
|
|
amount: 520,
|
|
invoice_count: 2,
|
|
occurred_at: '2026-05-13T01:00:00.000Z',
|
|
created_at: '2026-05-13T01:30:00.000Z',
|
|
updated_at: '2026-05-13T03:30:00.000Z',
|
|
status: 'draft',
|
|
approval_stage: '待提交',
|
|
risk_flags_json: [],
|
|
items: [
|
|
{
|
|
id: 'flight',
|
|
item_type: 'flight_ticket',
|
|
item_reason: '广州白云-上海虹桥',
|
|
item_location: '上海',
|
|
item_date: '2026-05-13',
|
|
item_amount: 320,
|
|
invoice_id: 'flight.png'
|
|
},
|
|
{
|
|
id: 'ship',
|
|
item_type: 'ship_ticket',
|
|
item_reason: '上海港-舟山港',
|
|
item_location: '舟山',
|
|
item_date: '2026-05-14',
|
|
item_amount: 200,
|
|
invoice_id: 'ship.png'
|
|
}
|
|
]
|
|
})
|
|
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'flight')?.detail, '起始地-目的地')
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'ship')?.detail, '起始地-目的地')
|
|
assert.equal(request.expenseItems.find((item) => item.id === 'ship')?.name, '轮船票')
|
|
})
|
|
|
|
test('completed finance approval marks finance and archive progress steps', () => {
|
|
const request = mapExpenseClaimToRequest({
|
|
id: 'claim-finance-completed',
|
|
claim_no: 'EXP-202605-004',
|
|
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-20T04:00:00.000Z',
|
|
status: 'approved',
|
|
approval_stage: '归档入账',
|
|
risk_flags_json: [
|
|
{
|
|
source: 'manual_approval',
|
|
operator: '李经理',
|
|
previous_approval_stage: '直属领导审批',
|
|
next_approval_stage: '财务审批',
|
|
created_at: '2026-05-20T03:00:00.000Z'
|
|
},
|
|
{
|
|
source: 'finance_approval',
|
|
operator: '财务复核',
|
|
previous_approval_stage: '财务审批',
|
|
next_approval_stage: '归档入账',
|
|
created_at: '2026-05-20T04:00:00.000Z'
|
|
}
|
|
],
|
|
items: []
|
|
})
|
|
|
|
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
|
const archiveStep = request.progressSteps.find((step) => step.label === '归档入账')
|
|
|
|
assert.equal(request.riskSummary, '无')
|
|
assert.equal(request.workflowNode, '归档入账')
|
|
assert.equal(financeStep.time, '财务复核通过')
|
|
assert.match(financeStep.detail, /2026-05-20/)
|
|
assert.equal(archiveStep.time, '归档入账')
|
|
assert.equal(archiveStep.done, true)
|
|
})
|
|
|
|
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
|
|
}
|
|
})
|