Files
X-Financial/web/tests/requestProgressSteps.test.mjs
caoxiaozhu 24b5b71b0f feat(web): 差旅申请详情进度 viewer 与审批/加载态组件增强
- 新增 requestProgressViewer,申请单在直属领导审批视角下将当前步骤展示为'等待批复',travel-request-detail/app-shell/useRequests 接入
- TravelRequestApprovalDialog 增强审批交互,TableLoadingState 补充表格加载占位,ConfirmDialog 扩展确认对话框能力
- useAppShell/useRequests/AppShellRouteView 配套适配申请详情跳转与会话状态
- 同步更新 requestProgressSteps、travel-request-detail-leader-approval、assistant-session-draft-delete、documents-center-status-filter、app-shell-financial-assistant-entry、request-progress-viewer 等测试
2026-06-21 22:49:58 +08:00

1253 lines
46 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 FINANCE_APPROVAL = '\u8d22\u52a1\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_APPROVAL = '\u7b49\u5f85\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 WAIT_FINANCE_FIONA_APPROVAL = '\u7b49\u5f85 Fiona Finance \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, '等待 Leader Li 批复', 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 === '等待 Leader Li 批复')?.rawLabel, DIRECT_MANAGER_APPROVAL)
assert.equal(request.progressSteps.find((step) => step.label === '等待 Leader Li 批复')?.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, 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, 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, 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: APPLICATION_LINK_STATUS,
risk_flags_json: [
{
source: 'approval_routing',
event_type: 'expense_application_route_decision',
requires_budget_review: true,
route: 'budget_manager',
budget_result: {
metrics: {
after_usage_rate: '99.27',
claim_amount_ratio: '1.27',
over_budget_amount: '0.00'
}
},
created_at: '2026-05-25T03:00:00.000Z'
},
{
source: 'manual_approval',
event_type: 'expense_application_approval',
operator: '李预算经理',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: APPLICATION_LINK_STATUS,
budget_approval_merged: true,
route_decision: {
requires_budget_review: true,
route: 'budget_manager',
budget_result: {
metrics: {
after_usage_rate: '99.27',
claim_amount_ratio: '1.27',
over_budget_amount: '0.00'
}
}
},
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, 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, 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 hide stale budget route below 90 percent threshold', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-stale-budget-route',
claim_no: 'APP-20260525-STALE-BUDGET',
employee_name: '张三',
department_name: '交付部',
manager_name: 'Leader Li',
expense_type: 'travel_application',
reason: 'Project onsite support',
location: 'Shanghai',
amount: 8500,
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: true,
route: 'budget_manager',
budget_result: {
metrics: {
after_usage_rate: '85.00',
claim_amount_ratio: '85.00',
over_budget_amount: '0.00'
}
},
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: BUDGET_MANAGER_APPROVAL,
route_decision: {
requires_budget_review: true,
route: 'budget_manager',
budget_result: {
metrics: {
after_usage_rate: '85.00',
claim_amount_ratio: '85.00',
over_budget_amount: '0.00'
}
}
},
created_at: '2026-05-25T03:00:00.000Z'
}
],
items: []
})
assert.deepEqual(
request.progressSteps.map((step) => step.label),
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPLICATION_LINK_STATUS, ARCHIVED]
)
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
})
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, 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: '市场部',
finance_owner_name: 'Wang Finance Group',
finance_approver_name: 'Fiona Finance',
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: FINANCE_APPROVAL,
risk_flags_json: [
{
source: 'manual_approval',
operator: '李经理',
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
next_approval_stage: FINANCE_APPROVAL,
created_at: '2026-05-20T03:30:00.000Z'
}
],
items: []
})
const leaderStep = request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)
const financeStep = request.progressSteps.find((step) => step.rawLabel === FINANCE_APPROVAL)
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(request.financeOwnerName, 'Wang Finance Group')
assert.equal(request.financeApproverName, 'Fiona Finance')
assert.equal(financeStep.label, WAIT_FINANCE_FIONA_APPROVAL)
assert.equal(financeStep.rawLabel, FINANCE_APPROVAL)
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: '市场部',
manager_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.rawLabel === '直属领导审批')
const submitStep = request.progressSteps.find((step) => step.label === '待提交')
assert.equal(submitStep.time, '王五提交')
assert.match(submitStep.detail, /2026-05-20/)
assert.equal(leaderStep.label, '等待批复')
assert.doesNotMatch(leaderStep.label, /李经理/)
assert.equal(leaderStep.rawLabel, '直属领导审批')
assert.equal(leaderStep.current, true)
assert.equal(leaderStep.time, '停留 3小时15分钟')
} finally {
Date.now = originalNow
}
})