Files
X-Financial/web/tests/requestProgressSteps.test.mjs
caoxiaozhu e124e4bbcb feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
2026-06-06 17:19:07 +08:00

1153 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict'
import test from 'node:test'
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
import {
buildApplicationDetailFactItems,
buildRelatedApplicationFactItems
} from '../src/utils/expenseApplicationDetail.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 APPLICATION_LINK_STATUS = '\u5173\u8054\u5355\u636e\u72b6\u6001'
const APPLICATION_ARCHIVE = '\u7533\u8bf7\u5f52\u6863'
const RETURNED = '\u9000\u56de'
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
const LINKED_APPLICATION = '\u5173\u8054\u5355\u636e'
const PAID = '\u5df2\u4ed8\u6b3e'
const ARCHIVED = '\u5df2\u5f52\u6863'
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
const WAIT_BUDGET_P8_EXECUTIVE_APPROVAL = '\u7b49\u5f85 P8 Executive \u6279\u590d'
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
test('claim mapper exposes employee identifier for reviewer risk profile lookup', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-profile-1',
claim_no: 'EXP-PROFILE-1',
employee_id: 'emp-profile-1',
employee_name: 'Alice',
department_name: 'Finance',
expense_type: 'travel',
reason: 'Trip',
location: 'Shanghai',
amount: 1200,
invoice_count: 1,
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: DIRECT_MANAGER_APPROVAL,
risk_flags_json: [],
items: []
})
assert.equal(request.employeeId, 'emp-profile-1')
assert.equal(request.employee_id, 'emp-profile-1')
assert.equal(request.profileEmployeeId, 'emp-profile-1')
})
test('claim mapper falls back to employee name for legacy profile lookup', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-profile-legacy',
claim_no: 'EXP-PROFILE-LEGACY',
employee_name: 'Legacy Alice',
department_name: 'Finance',
expense_type: 'travel',
reason: 'Trip',
location: 'Shanghai',
amount: 1200,
invoice_count: 1,
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: DIRECT_MANAGER_APPROVAL,
risk_flags_json: [],
items: []
})
assert.equal(request.employeeId, '')
assert.equal(request.employee_id, '')
assert.equal(request.profileEmployeeId, 'Legacy Alice')
})
test('claim mapper keeps low reimbursement risk as low risk instead of medium', () => {
const riskMessage = '票据商品或服务描述较笼统,建议审批人核对真实用途和明细清单。'
const request = mapExpenseClaimToRequest({
id: 'claim-low-risk-1',
claim_no: 'RE-LOW-RISK-1',
employee_name: 'Alice',
department_name: 'Finance',
expense_type: 'travel',
reason: 'Trip',
location: 'Shanghai',
amount: 354,
invoice_count: 1,
occurred_at: '2026-02-20T00:00:00.000Z',
created_at: '2026-06-03T04:22:16.000Z',
updated_at: '2026-06-03T04:25:48.000Z',
status: 'draft',
approval_stage: WAIT_SUBMIT,
risk_flags_json: [
{
source: 'submission_review',
hit_source: 'rule_center',
severity: 'low',
action: 'warning',
label: '差旅票据服务内容笼统低风险',
message: riskMessage,
risk_domain: 'invoice',
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter',
business_stage: 'reimbursement'
}
],
items: [
{
id: 'item-low-risk-train',
item_date: '2026-02-20',
item_type: 'train_ticket',
item_reason: '武汉-上海',
item_location: '',
item_amount: 354,
invoice_id: 'claim-low-risk-1/item-low-risk-train/train.pdf'
}
]
})
assert.equal(request.riskTone, 'low')
assert.equal(request.riskLabel, '低风险')
assert.equal(request.riskSummary, riskMessage)
assert.equal(request.expenseItems[0].riskTone, 'low')
assert.equal(request.expenseItems[0].riskLabel, '低风险')
assert.equal(request.expenseItems[0].riskText, riskMessage)
})
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, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
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.some((step) => step.label === BUDGET_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('travel application detail splits trip time into departure and return rows', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-trip-time',
claim_no: 'AP-20260602103045-TRIPTIME',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: '支撑国网仿生产环境部署',
location: '上海',
amount: 3000,
invoice_count: 0,
occurred_at: '2026-02-20T00:00:00.000Z',
submitted_at: '2026-02-20T02:00:00.000Z',
created_at: '2026-02-20T01:30:00.000Z',
updated_at: '2026-02-20T02:00:00.000Z',
status: 'submitted',
approval_stage: '直属领导审批',
risk_flags_json: [
{
source: 'application_detail',
application_detail: {
application_type: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '支撑国网仿生产环境部署',
days: '4 天',
transport_mode: '火车',
amount: '3000'
}
}
],
items: []
})
const factItems = buildApplicationDetailFactItems(request)
assert.deepEqual(
factItems
.filter((item) => ['trip_start_time', 'trip_return_time'].includes(item.key))
.map((item) => [item.label, item.value]),
[
['出发时间', '2026-02-20'],
['返回时间', '2026-02-23']
]
)
assert.equal(factItems.some((item) => item.label === '发生时间'), false)
assert.equal(factItems.some((item) => item.label === '行程时间'), false)
})
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, APPLICATION_LINK_STATUS, ARCHIVED]
)
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('application budget wait label uses claim-level budget approver snapshot', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-budget-snapshot',
claim_no: 'AP-20260525103145-BUDGET-SNAPSHOT',
employee_name: 'Applicant Zhang',
department_name: 'Engineering',
manager_name: 'Leader Li',
budget_approver_name: 'P8 Executive',
budget_approver_grade: 'P8',
budget_approver_role_code: 'executive',
expense_type: 'travel_application',
reason: 'Production deployment support',
location: 'Beijing',
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,
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.equal(request.budgetApproverName, 'P8 Executive')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_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, 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, APPLICATION_LINK_STATUS)
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.time, '未关联')
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李经理通过')
assert.equal(request.progressSteps.find((step) => step.label === BUDGET_MANAGER_APPROVAL)?.time, '赵预算通过')
})
test('application claims hide budget step when leader approval also covers budget approval', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-merged-budget',
claim_no: 'APP-20260525-MERGED',
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: APPROVAL_COMPLETED,
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: '李预算经理',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: APPROVAL_COMPLETED,
budget_approval_merged: true,
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李预算经理通过')
})
test('approved application claims hide budget step when dynamic route skipped budget review', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-skipped-budget',
claim_no: 'APP-20260525-SKIPPED',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: 'Project onsite support',
location: 'Shanghai',
amount: 500,
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: APPROVAL_COMPLETED,
risk_flags_json: [
{
source: 'approval_routing',
event_type: 'expense_application_route_decision',
requires_budget_review: false,
route: 'approval_done',
created_at: '2026-05-25T03:00:00.000Z'
},
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'Leader Li',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: APPROVAL_COMPLETED,
route_decision: {
requires_budget_review: false,
route: 'approval_done'
},
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)?.current, true)
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
})
test('approved application claims show linked reimbursement status before archive', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-linked-draft',
claim_no: 'AP-202606050001-LINKED',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: 'Project onsite support',
location: 'Shanghai',
amount: 500,
invoice_count: 0,
occurred_at: '2026-06-05T00:00:00.000Z',
submitted_at: '2026-06-05T02:00:00.000Z',
created_at: '2026-06-05T01:30:00.000Z',
updated_at: '2026-06-05T03:00:00.000Z',
status: 'approved',
approval_stage: APPLICATION_LINK_STATUS,
risk_flags_json: [
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: 'Leader Li',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: APPLICATION_LINK_STATUS,
generated_draft_claim_no: 'RE-202606050001-LINKED',
created_at: '2026-06-05T03:00:00.000Z'
}
],
items: []
})
const linkStep = request.progressSteps.find((step) => step.label === APPLICATION_LINK_STATUS)
assert.equal(request.workflowNode, APPLICATION_LINK_STATUS)
assert.equal(linkStep?.current, true)
assert.equal(linkStep?.time, '关联中 RE-202606050001-LINKED')
assert.equal(request.secondaryStatusValue, '关联中 RE-202606050001-LINKED')
assert.equal(request.progressSteps.find((step) => step.label === ARCHIVED)?.done, false)
})
test('application claims are archived only after linked reimbursement is paid', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-archived',
claim_no: 'AP-202606050001-ARCHIVED',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: 'Project onsite support',
location: 'Shanghai',
amount: 500,
invoice_count: 0,
occurred_at: '2026-06-05T00:00:00.000Z',
submitted_at: '2026-06-05T02:00:00.000Z',
created_at: '2026-06-05T01:30:00.000Z',
updated_at: '2026-06-07T03:00:00.000Z',
status: 'approved',
approval_stage: APPLICATION_ARCHIVE,
risk_flags_json: [
{
source: 'application_archive_sync',
event_type: 'expense_application_archived_by_reimbursement',
reimbursement_claim_no: 'RE-202606050001-ARCHIVED',
created_at: '2026-06-07T03:00:00.000Z'
}
],
items: []
})
assert.equal(request.workflowNode, APPLICATION_ARCHIVE)
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.every((step) => step.done), true)
assert.equal(request.secondaryStatusValue, '已归档')
})
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 firstStep = request.progressSteps[0]
assert.equal(request.riskSummary, '无')
assert.equal(firstStep.label, LINKED_APPLICATION)
assert.equal(leaderStep.time, '李经理通过')
assert.match(leaderStep.detail, /2026-05-20/)
assert.match(leaderStep.title, /李经理审批通过/)
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
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('finance approval moves reimbursement to pending payment step', () => {
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: 'pending_payment',
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 paymentStep = request.progressSteps.find((step) => step.label === '待付款')
assert.equal(request.riskSummary, '无')
assert.equal(request.workflowNode, '待付款')
assert.equal(request.approvalKey, 'pending_payment')
assert.equal(financeStep.time, '财务复核通过')
assert.match(financeStep.detail, /2026-05-20/)
assert.equal(paymentStep.current, true)
assert.equal(paymentStep.done, false)
})
test('paid reimbursement marks payment progress step as complete', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-finance-paid',
claim_no: 'EXP-202605-005',
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-20T05:00:00.000Z',
status: 'paid',
approval_stage: '已付款',
risk_flags_json: [
{
source: 'finance_approval',
operator: '财务复核',
previous_approval_stage: '财务审批',
next_approval_stage: '待付款',
created_at: '2026-05-20T04:00:00.000Z'
},
{
source: 'payment',
event_type: 'expense_claim_payment_completed',
operator: '财务付款',
previous_approval_stage: '待付款',
next_approval_stage: '已付款',
created_at: '2026-05-20T05:00:00.000Z'
},
{
source: 'application_handoff',
event_type: 'expense_application_to_reimbursement_draft',
application_claim_id: 'application-1',
application_claim_no: 'APP-20260520-001',
application_detail: {
application_type: '差旅费用申请',
application_content: '差旅费用申请 / 北京',
application_reason: '支撑国网仿生产环境部署',
application_days: '4 天',
application_location: '北京',
application_amount: '3000',
application_time: '2026-05-20 至 2026-05-23',
application_transport_mode: '高铁',
application_lodging_daily_cap: '600元/天',
application_subsidy_daily_cap: '120元/天',
application_transport_policy: '按真实票据复核',
application_policy_estimate: '交通按真实票据 + 住宿 2,400元 + 补贴 480元',
application_rule_name: '差旅标准规则',
application_rule_version: '2026.05'
}
}
],
items: []
})
const paymentStep = request.progressSteps.find((step) => step.label === '待付款')
const paidStep = request.progressSteps.find((step) => step.label === PAID)
const archivedStep = request.progressSteps.find((step) => step.label === ARCHIVED)
const linkedStep = request.progressSteps.find((step) => step.label === LINKED_APPLICATION)
assert.equal(request.workflowNode, '已付款')
assert.equal(request.approvalStatus, '已付款')
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[LINKED_APPLICATION, '待提交', '直属领导审批', '财务审批', '待付款', PAID, ARCHIVED]
)
assert.equal(paymentStep.time, '待付款')
assert.equal(paidStep.time, '已付款')
assert.equal(paidStep.done, true)
assert.equal(archivedStep.time, ARCHIVED)
assert.equal(archivedStep.done, true)
assert.equal(linkedStep.time, '已关联 APP-20260520-001')
assert.equal(request.relatedApplication.claimNo, 'APP-20260520-001')
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
assert.equal(request.relatedApplication.days, '4 天')
assert.equal(request.relatedApplication.time, '2026-05-20 至 2026-05-23')
assert.equal(request.relatedApplication.tripStartDate, '2026-05-20')
assert.equal(request.relatedApplication.tripEndDate, '2026-05-23')
assert.equal(request.relatedApplication.transportMode, '高铁')
assert.equal(request.relatedApplication.lodgingDailyCap, '600元/天')
assert.equal(request.relatedApplication.subsidyDailyCap, '120元/天')
assert.equal(request.relatedApplication.transportPolicy, '按真实票据复核')
assert.equal(request.relatedApplication.policyEstimate, '交通按真实票据 + 住宿 2,400元 + 补贴 480元')
assert.equal(request.relatedApplication.ruleLabel, '差旅标准规则 / 2026.05')
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
assert.deepEqual(
buildRelatedApplicationFactItems(request).map((item) => [item.label, item.value]),
[
['关联单据单号', 'APP-20260520-001'],
['申请内容', '差旅费用申请 / 北京'],
['出发时间', '2026-05-20'],
['返回时间', '2026-05-23'],
['申请天数', '4 天'],
['申请事由', '支撑国网仿生产环境部署'],
['申请地点', '北京'],
['出行方式', '高铁'],
['住宿上限/天', '600元/天'],
['补贴标准/天', '120元/天'],
['交通费用口径', '按真实票据复核'],
['规则测算参考', '交通按真实票据 + 住宿 2,400元 + 补贴 480元'],
['规则依据', '差旅标准规则 / 2026.05'],
['预计金额', '¥3,000']
]
)
})
test('reimbursement detail resolves linked application from guided entry context', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-linked-context',
claim_no: 'EXP-20260520-009',
employee_name: '张三',
department_name: '交付部',
expense_type: 'travel',
reason: '支撑国网仿生产环境部署',
location: '北京',
amount: 654,
invoice_count: 1,
occurred_at: '2026-05-20T01:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T02:00:00.000Z',
status: 'draft',
approval_stage: '待提交',
risk_flags_json: [
{
source: 'application_link',
event_type: 'expense_reimbursement_application_linked',
review_form_values: {
application_claim_id: 'application-guided-1',
application_claim_no: 'AP-202605-001',
application_reason: '支撑国网仿生产环境部署',
application_location: '北京',
application_amount: '3000',
application_amount_label: '¥3,000',
application_business_time: '2026-05-20 至 2026-05-23',
application_days: '4 天',
application_transport_mode: '高铁',
application_lodging_daily_cap: '600元/天',
application_subsidy_daily_cap: '120元/天',
application_transport_policy: '按真实票据复核',
application_policy_estimate: '交通按真实票据 + 住宿 2,400元 + 补贴 480元'
},
expense_scene_selection: {
application_claim_no: 'AP-202605-001'
}
}
],
items: []
})
assert.equal(request.relatedApplication.claimNo, 'AP-202605-001')
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
assert.equal(request.relatedApplication.location, '北京')
assert.equal(request.relatedApplication.time, '2026-05-20 至 2026-05-23')
assert.equal(request.relatedApplication.tripStartDate, '2026-05-20')
assert.equal(request.relatedApplication.tripEndDate, '2026-05-23')
assert.equal(request.relatedApplication.days, '4 天')
assert.equal(request.relatedApplication.transportMode, '高铁')
assert.equal(request.relatedApplication.lodgingDailyCap, '600元/天')
assert.equal(request.relatedApplication.subsidyDailyCap, '120元/天')
assert.equal(request.relatedApplication.policyEstimate, '交通按真实票据 + 住宿 2,400元 + 补贴 480元')
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
assert.deepEqual(request.expenseItems, [])
})
test('reimbursement detail hides stale application placeholder and allowance rows before receipts', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-linked-stale-placeholder',
claim_no: 'EXP-20260520-010',
employee_name: '张三',
department_name: '交付部',
expense_type: 'travel',
reason: '支撑国网仿生产环境部署',
location: '上海',
amount: 3480,
invoice_count: 0,
occurred_at: '2026-02-20T00:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T02:00:00.000Z',
status: 'draft',
approval_stage: '待提交',
risk_flags_json: [
{
source: 'application_link',
event_type: 'expense_reimbursement_application_linked',
review_form_values: {
application_claim_id: 'application-guided-stale',
application_claim_no: 'AP-202605-010',
application_reason: '支撑国网仿生产环境部署',
application_location: '上海',
application_amount: '3000',
application_amount_label: '¥3,000',
application_business_time: '2026-02-20 至 2026-02-23',
application_days: '4 天',
application_transport_mode: '火车'
}
}
],
items: [
{
id: 'placeholder-travel',
item_type: 'travel',
item_reason: '支撑国网仿生产环境部署',
item_location: '上海',
item_amount: 3000,
item_date: '2026-02-20',
invoice_id: ''
},
{
id: 'stale-allowance',
item_type: 'travel_allowance',
item_reason: '系统自动计算出差补贴上海市1天120.00元/天',
item_location: '直辖市/特区',
item_amount: 120,
item_date: '2026-02-20',
invoice_id: ''
}
]
})
assert.equal(request.relatedApplication.claimNo, 'AP-202605-010')
assert.equal(request.relatedApplication.days, '4 天')
assert.equal(request.amount, 0)
assert.deepEqual(request.expenseItems, [])
assert.equal(request.expenseTableSummary, '暂无费用明细')
})
test('reimbursement detail hides stale allowance when linked application days differ', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-linked-stale-allowance',
claim_no: 'EXP-20260520-011',
employee_name: '张三',
department_name: '交付部',
expense_type: 'travel',
reason: '支撑国网仿生产环境部署',
location: '上海',
amount: 474,
invoice_count: 1,
occurred_at: '2026-02-20T00:00:00.000Z',
created_at: '2026-05-20T01:30:00.000Z',
updated_at: '2026-05-20T02:00:00.000Z',
status: 'draft',
approval_stage: '待提交',
risk_flags_json: [
{
source: 'application_link',
event_type: 'expense_reimbursement_application_linked',
application_claim_no: 'AP-202605-011',
application_detail: {
application_reason: '支撑国网仿生产环境部署',
application_location: '上海',
application_time: '2026-02-20 至 2026-02-23',
application_days: '4 天',
application_transport_mode: '火车'
}
}
],
items: [
{
id: 'outbound-train',
item_type: 'train_ticket',
item_reason: '武汉-上海',
item_location: '上海',
item_amount: 354,
item_date: '2026-02-20',
invoice_id: 'ticket-1.png'
},
{
id: 'stale-allowance',
item_type: 'travel_allowance',
item_reason: '系统自动计算出差补贴上海市1天120.00元/天',
item_location: '直辖市/特区',
item_amount: 120,
item_date: '2026-02-20',
invoice_id: ''
}
]
})
assert.equal(request.relatedApplication.days, '4 天')
assert.deepEqual(request.expenseItems.map((item) => item.id), ['outbound-train'])
assert.equal(request.amount, 354)
assert.equal(request.expenseTableSummary, '共 1 条费用明细,已关联 1 张票据')
})
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
}
})