feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -35,6 +35,10 @@ const assistantSubmitComposerScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
'utf8'
)
const assistantSessionStateScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
'utf8'
)
const assistantTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
'utf8'
@@ -60,6 +64,17 @@ test('application and reimbursement entries open the same financial assistant mo
assert.doesNotMatch(appShellRouteView, /ExpenseApplicationDialog/)
})
test('documents center reloads immediately when entered or clicked again', () => {
assert.match(appShellRouteView, /:refresh-token="documentCenterRefreshToken"/)
assert.match(appShellRouteView, /@reload="reloadRequests"/)
assert.match(appShellComposable, /const documentCenterRefreshToken = ref\(0\)/)
assert.match(appShellComposable, /async function reloadDocumentCenterRequests\(\) \{[\s\S]*documentCenterRefreshToken\.value \+= 1[\s\S]*return reloadRequests\(\)/)
assert.match(appShellComposable, /if \(view === 'documents'\) \{[\s\S]*void reloadDocumentCenterRequests\(\)/)
assert.match(appShellComposable, /shouldRefreshCurrentDocumentCenter[\s\S]*route\.name === 'app-documents'[\s\S]*void reloadDocumentCenterRequests\(\)/)
assert.match(appShellComposable, /documentCenterRefreshToken,/)
assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
})
test('application entry keeps its own assistant source without creating a separate dialog', () => {
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)
@@ -68,6 +83,32 @@ test('application entry keeps its own assistant source without creating a separa
assert.match(assistantScript, /activeSessionType\.value === SESSION_TYPE_APPLICATION[\s\S]*我想先申请一笔差旅费用/)
})
test('application edit prefill opens assistant without auto submit', () => {
assert.match(appShellRouteView, /:initial-prompt-auto-submit="smartEntryContext\.initialPromptAutoSubmit"/)
assert.match(appShellRouteView, /:initial-application-preview="smartEntryContext\.initialApplicationPreview"/)
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*true/)
assert.match(appShellComposable, /initialApplicationPreview:\s*null/)
assert.match(appShellComposable, /initialPromptAutoSubmit:\s*payload\.initialPromptAutoSubmit !== false/)
assert.match(appShellComposable, /initialApplicationPreview:\s*payload\.applicationPreview && typeof payload\.applicationPreview === 'object'/)
assert.match(
assistantScript,
/initialPromptAutoSubmit:\s*\{[\s\S]*type:\s*Boolean[\s\S]*default:\s*true/
)
assert.match(
assistantScript,
/initialApplicationPreview:\s*\{[\s\S]*type:\s*Object[\s\S]*default:\s*null/
)
assert.match(
assistantScript,
/props\.initialApplicationPreview[\s\S]*normalizeApplicationPreview\(props\.initialApplicationPreview\)[\s\S]*createMessage\('assistant', buildLocalApplicationPreviewMessage\(applicationPreview\)/
)
assert.match(assistantSessionStateScript, /&& !props\.initialApplicationPreview/)
assert.match(
assistantScript,
/if \(props\.initialPromptAutoSubmit !== false\) \{[\s\S]*submitComposer\(\)/
)
})
test('financial assistant toolbar renders four isolated assistant sessions', () => {
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
assert.match(assistantScript, /visibleModes\.map/)

View File

@@ -4,7 +4,9 @@ import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
compactDigitalEmployeeWorkRecords,
extractWorkRecordToolSummary,
isVisibleDigitalEmployeeWorkRecord,
resolveWorkRecordModuleLabel,
resolveWorkRecordProductKind,
resolveWorkRecordTaskType,
@@ -69,6 +71,55 @@ test('digital employee profile run resolves from tool request when route is spar
assert.equal(extractWorkRecordToolSummary(run).snapshot_count, 16)
})
test('digital employee finance snapshot and reminder runs are visible core work records', () => {
const financeRun = {
run_id: 'finance-1',
started_at: '2026-06-02T02:00:00+08:00',
route_json: { task_type: 'finance_dashboard_snapshot' },
tool_calls: [{ tool_name: 'digital_employee.finance_dashboard.snapshot' }]
}
const reminderRun = {
run_id: 'reminder-1',
started_at: '2026-06-02T02:05:00+08:00',
route_json: { task_type: 'digital_employee_reminder_scan' },
tool_calls: [{ tool_name: 'digital_employee.reminder.scan' }]
}
assert.equal(resolveWorkRecordModuleLabel(financeRun), '财务经营快照沉淀')
assert.equal(resolveWorkRecordProductKind(financeRun), 'finance_snapshot')
assert.equal(isVisibleDigitalEmployeeWorkRecord(financeRun), true)
assert.equal(resolveWorkRecordModuleLabel(reminderRun), '定时提醒与待办扫描')
assert.equal(resolveWorkRecordProductKind(reminderRun), 'reminder_scan')
assert.equal(isVisibleDigitalEmployeeWorkRecord(reminderRun), true)
})
test('digital employee work records hide support tasks and compact daily finance snapshots', () => {
const rows = compactDigitalEmployeeWorkRecords([
{
run_id: 'finance-new',
started_at: '2026-06-02T03:00:00+08:00',
route_json: { task_type: 'finance_dashboard_snapshot' }
},
{
run_id: 'finance-old',
started_at: '2026-06-02T02:00:00+08:00',
route_json: { task_type: 'finance_dashboard_snapshot' }
},
{
run_id: 'profile-1',
started_at: '2026-06-02T08:30:00+08:00',
route_json: { task_type: 'employee_behavior_profile_scan' }
},
{
run_id: 'support-1',
started_at: '2026-06-02T10:00:00+08:00',
route_json: { task_type: 'risk_clue_collect' }
}
])
assert.deepEqual(rows.map((run) => run.run_id), ['finance-new', 'profile-1'])
})
test('digital employee risk clue run resolves review packet metadata', () => {
const run = {
route_json: {

View File

@@ -85,6 +85,15 @@ test('documents center preserves application document type from mapped requests'
)
})
test('documents center refresh token reloads supporting approval and archive rows', () => {
assert.match(documentsCenterView, /refreshToken:\s*\{\s*type:\s*Number,\s*default:\s*0\s*\}/)
assert.match(
documentsCenterView,
/watch\(\s*\(\) => props\.refreshToken,[\s\S]*if \(token && token !== previousToken\) \{[\s\S]*void loadSupportingRows\(\)/
)
assert.match(documentsCenterView, /function reloadAll\(\) \{[\s\S]*emit\('reload'\)[\s\S]*void loadSupportingRows\(\)/)
})
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">/)
@@ -215,7 +224,7 @@ test('documents center switches filter conditions by category tab', () => {
documentsCenterView,
/watch\(activeFilterConfig, \(\) => \{[\s\S]*openFilterKey\.value = ''[\s\S]*datePopover\.value = false/
)
assert.match(documentsCenterView, /<EnterpriseSelect v-model="pageSize"[\s\S]*:options="pageSizeOptions"/)
assert.match(documentsCenterView, /<EnterprisePagination[\s\S]*:page-size="pageSize"[\s\S]*:page-size-options="pageSizeOptions"/)
assert.doesNotMatch(documentsCenterView, /pageSizeOpen/)
})

View File

@@ -24,6 +24,13 @@ import {
resolveMockApplicationTransportWaitMs,
buildSystemApplicationEstimate
} from '../src/utils/expenseApplicationEstimate.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningNudgeMessage,
buildTravelPlanningRecommendation,
buildTravelPlanningSuggestedActions
} from '../src/utils/travelApplicationPlanning.js'
import { renderMarkdown } from '../src/utils/markdown.js'
import {
createMessage as createConversationMessage,
@@ -142,6 +149,34 @@ test('application intent uses local preview instead of immediate orchestrator ca
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('travel application submit can continue with conversational planning recommendation', () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海市',
reason: '支撑国网仿生产环境建设',
days: '4天',
transportMode: '火车'
}
})
const draftPayload = { claim_no: 'AP-202606030001-ABCDE123' }
const nudge = buildTravelPlanningNudgeMessage(preview, draftPayload)
const actions = buildTravelPlanningSuggestedActions(preview, draftPayload)
const recommendation = buildTravelPlanningRecommendation(preview, draftPayload)
assert.match(nudge, /上海市差旅申请已经提交/)
assert.match(nudge, /2026-02-20 至 2026-02-23/)
assert.deepEqual(actions.map((item) => item.action_type), [
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP
])
assert.match(recommendation, /轻量行程规划/)
assert.match(recommendation, /优先看上午到中午抵达 上海市 的火车班次/)
assert.match(recommendation, /客户现场周边/)
assert.match(recommendation, /AP-202606030001-ABCDE123/)
})
test('application preview renders ordered editable rows and submit text uses edited values', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆伊犁出差服务美团业务部署火车预计费用1800元', {
name: '李文静',
@@ -767,3 +802,63 @@ test('application preview editor refreshes transport estimate after mode change'
assert.ok(persistCount >= 2)
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
})
test('application preview editor recalculates days and subsidy after date range change', async () => {
const preview = normalizeApplicationPreview({
fields: {
applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7',
time: '2026-05-25',
location: '\u4e0a\u6d77',
reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72',
days: '1\u5929',
transportMode: '\u706b\u8f66',
amount: '',
grade: 'P5',
applicant: '\u674e\u6587\u9759',
department: '\u6280\u672f\u90e8',
position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406',
managerName: '\u5411\u4e07\u7ea2'
}
})
const message = {
id: 'application-preview-editor-date-message',
applicationPreview: preview,
text: ''
}
const requestedPayloads = []
const editor = useApplicationPreviewEditor({
persistSessionState: () => {},
toast: () => {},
currentUser: ref({ grade: 'P5' }),
calculateTravelReimbursement: async (payload) => {
requestedPayloads.push(payload)
return {
days: payload.days,
location: payload.location,
matched_city: payload.location,
grade: payload.grade,
hotel_rate: 450,
hotel_amount: 1800,
total_allowance_rate: 100,
allowance_amount: 400,
total_amount: 2200,
rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219',
rule_version: 'v1.0.0'
}
}
})
editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time)
editor.setApplicationPreviewDateMode('range')
editor.applicationPreviewEditor.value.rangeStartDate = '2026-02-20'
editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-23'
const committed = await editor.commitApplicationPreviewDateEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), { days: 4, location: '\u4e0a\u6d77', grade: 'P5' })
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')
assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929')
assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/)
})

View File

@@ -51,4 +51,10 @@ test('expense application submit uses rich text link and confirm dialog', () =>
createViewScript,
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
)
assert.match(createViewScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
assert.match(createViewScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
assert.match(createViewScript, /meta:\s*\['行程规划推荐'\]/)
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
assert.match(createViewScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
assert.match(createViewScript, /TRAVEL_PLANNING_ACTION_SKIP/)
})

View File

@@ -0,0 +1,53 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { departmentRangeOptions } from '../src/data/metrics.js'
const overviewView = readFileSync(
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
'utf8'
)
const overviewViewModel = readFileSync(
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
'utf8'
)
const analyticsService = readFileSync(
fileURLToPath(new URL('../src/services/analytics.js', import.meta.url)),
'utf8'
)
const barChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/BarChart.vue', import.meta.url)),
'utf8'
)
test('finance dashboard ranking range options support month quarter year and all', () => {
assert.deepEqual(departmentRangeOptions, ['本月', '本季度', '本年', '全部'])
assert.match(analyticsService, /department_employee_mix/)
assert.match(analyticsService, /departmentEmployeeMix/)
assert.match(analyticsService, /department_range/)
})
test('finance dashboard renders shared ranking filters and department employee mix chart', () => {
assert.match(overviewView, /<h3>部门报销排行/)
assert.match(overviewView, /aria-label="部门排行时间范围"/)
assert.match(overviewView, /<h3>个人报销排行/)
assert.match(overviewView, /aria-label="个人排行时间范围"/)
assert.doesNotMatch(overviewView, /个人报销排行(本月)/)
assert.match(overviewView, /<h3>高额单据/)
assert.doesNotMatch(overviewView, /本月高额单据/)
assert.match(overviewView, /class="top-claim-split"/)
assert.match(overviewView, /departmentEmployeeLegend/)
assert.match(overviewView, /departmentEmployeeCenterValue/)
assert.match(overviewViewModel, /financeDepartmentEmployeeMix/)
assert.match(overviewViewModel, /departmentEmployeeLegend/)
assert.match(overviewViewModel, /employeeCount/)
})
test('finance ranking bar chart can display ranking metadata', () => {
assert.match(barChart, /rank-meta/)
assert.match(barChart, /item\.meta/)
assert.match(overviewViewModel, /meta: `\$\{Number\(item\.employeeCount/)
assert.match(overviewViewModel, /meta: `\$\{item\.department/)
})

View File

@@ -121,8 +121,10 @@ test('workbench cards use layered glass material instead of texture-led cards',
assert.doesNotMatch(workbenchCardStyles, /background-blend-mode:\s*normal,\s*color,\s*normal;/)
assert.match(workbenchResponsiveStyles, /--workbench-glass-noise-opacity:\s*0\.008;/)
assert.match(workbenchResponsiveStyles, /--workbench-glass-blur:\s*blur\(14px\) saturate\(1\.2\);/)
assert.match(workbenchGlassStyles, /\.todo-row,[\s\S]*\.progress-row\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*inset 0 1px 0 rgba\(var\(--theme-primary-rgb/)
assert.doesNotMatch(workbenchGlassStyles, /\.todo-row\s*\{[\s\S]*border-top:\s*1px solid var\(--workbench-line-soft\)/)
assert.match(workbenchGlassStyles, /\.progress-row\s*\{[\s\S]*background:\s*transparent;[\s\S]*box-shadow:\s*inset 0 1px 0 rgba\(var\(--theme-primary-rgb/)
assert.doesNotMatch(workbench, /<h2>我的待办<\/h2>/)
assert.doesNotMatch(workbench, /<h2>关键动作<\/h2>/)
assert.doesNotMatch(workbenchGlassStyles, /\.todo-row/)
assert.doesNotMatch(workbenchGlassStyles, /\.progress-row\s*\{[\s\S]*border-top:\s*1px solid var\(--workbench-line-soft\)/)
assert.match(workbenchInsightStyles, /\.insight-metric-row,[\s\S]*\.insight-profile-card\s*\{[\s\S]*backdrop-filter:\s*blur\(10px\) saturate\(1\.16\)/)
assert.doesNotMatch(workbenchInsightStyles, /background:\s*#ffffff;/)

View File

@@ -82,3 +82,16 @@ test('risk dashboard wires window filter to trend, ranking, and cards data sourc
assert.match(dashboardComponent, /RiskDailyTrendChart/)
assert.match(dashboardComponent, /rankingGroups/)
})
test('risk dashboard shows loading overlay and realtime refresh status', () => {
assert.match(dashboardComponent, /risk-dashboard-loading-overlay/)
assert.match(dashboardComponent, /loadingLabel/)
assert.match(dashboardComponent, /lastUpdatedLabel/)
assert.match(dashboardComponent, /lastUpdatedAt/)
assert.match(overviewViewModel, /riskDashboardLastUpdatedAt/)
assert.match(overviewViewModel, /startRiskDashboardRealtimeRefresh/)
assert.match(overviewViewModel, /setInterval/)
assert.match(overviewViewModel, /document\.visibilityState === 'hidden'/)
assert.match(overviewViewModel, /riskDashboardRequestSeq/)
assert.match(overviewTemplate, /:last-updated-at="riskDashboardLastUpdatedAt"/)
})

View File

@@ -805,6 +805,29 @@ test('application detail uses application labels instead of reimbursement labels
assert.match(detailViewTemplate, /当前申请单已进入流程,详情页仅展示状态与申请信息。/)
})
test('returned application detail can open assistant with editable prefill', () => {
assert.match(
detailViewTemplate,
/v-if="canModifyReturnedApplication"[\s\S]*@click="handleModifyApplication"[\s\S]*修改申请/
)
assert.match(
detailViewScript,
/const canModifyReturnedApplication = computed\(\(\) => \([\s\S]*isApplicationDocument\.value[\s\S]*isCurrentApplicant\.value[\s\S]*returned/
)
assert.match(detailViewScript, /function buildApplicationEditPreview\(\)/)
assert.match(detailViewScript, /applicationDetailFactItems\.value[\s\S]*sourceText:\s*'修改申请'/)
assert.match(detailViewScript, /fields:\s*\{[\s\S]*applicationType:[\s\S]*reason:[\s\S]*transportMode:/)
assert.match(detailViewScript, /function handleModifyApplication\(\)/)
assert.match(detailViewScript, /source:\s*'application'/)
assert.match(detailViewScript, /sessionType:\s*'application'/)
assert.match(detailViewScript, /prompt:\s*''/)
assert.match(detailViewScript, /applicationPreview:\s*buildApplicationEditPreview\(\)/)
assert.match(detailViewScript, /applicationEditMode:\s*true/)
assert.match(detailViewScript, /initialPromptAutoSubmit:\s*false/)
assert.match(detailViewScript, /canModifyReturnedApplication,/)
assert.match(detailViewScript, /handleModifyApplication,/)
})
test('application detail does not show optional travel receipt reminders', () => {
const request = {
documentTypeCode: 'application',
@@ -943,6 +966,34 @@ test('transport ticket items no longer generate business location completion adv
assert.doesNotMatch(detailViewScript, /完善第 1 条费用明细的业务地点/)
})
test('compliant attachment analysis does not create medium risk cards', () => {
const riskCards = buildAttachmentRiskCards({
expenseItems: [
{
id: 'item-001',
invoiceId: 'mock/invoice-001.txt',
itemReason: 'taxi',
itemType: 'transport'
}
],
attachmentMetaByItemId: {
'item-001': {
analysis: {
severity: 'success',
label: 'compliant',
headline: 'invoice fields match reimbursement item',
summary: 'mock OCR fields are consistent with the reimbursement detail',
points: ['amount and document type are consistent']
}
}
}
})
assert.deepEqual(riskCards, [])
assert.match(detailViewInsights, /success', 'ok', 'normal', 'none', 'compliant', 'approved'/)
assert.match(detailViewScript, /tone: normalizeRiskTone\(analysis\.severity \|\| 'low'\)/)
})
test('return reason dialog is wired into approval and detail return actions', () => {
assert.match(returnReasonDialog, /missing_attachment/)
assert.match(returnReasonDialog, /invoice_mismatch/)

View File

@@ -94,8 +94,9 @@ test('archived detail delete action is gated by admin-only permission', () => {
test('editable detail delete action is limited to applicant or claim manager', () => {
assert.match(detailViewScript, /const isCurrentApplicant = computed/)
assert.match(detailViewScript, /isPlatformAdminUser/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\)\s*}/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return isPlatformAdminUser\(currentUser\.value\) \|\| \(isEditableRequest\.value && isCurrentApplicant\.value\)\s*}/)
assert.match(detailViewScript, /if \(canManageCurrentClaim\.value\) {\s*return true\s*}/)
assert.match(detailViewScript, /return isEditableRequest\.value && isCurrentApplicant\.value/)
assert.match(detailViewScript, /if \(isApplicationDocument\.value\) {\s*return '删除申请'\s*}/)
assert.match(detailViewScript, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
})

View File

@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildAdjacentProgressSteps,
buildWorkbenchSummary
} from '../src/utils/workbenchSummary.js'
const currentUser = { name: '张三', username: 'zhangsan' }
function buildStep(label, index, currentIndex) {
return {
label,
done: index < currentIndex,
active: index <= currentIndex,
current: index === currentIndex,
time: index === currentIndex ? '进行中' : '待处理'
}
}
test('workbench expense progress keeps only nearby four expense steps', () => {
const steps = ['创建单据', '待提交', '直属领导审批', '财务审批', '待付款', '归档入账']
.map((label, index) => buildStep(label, index, 3))
const visibleSteps = buildAdjacentProgressSteps(steps, 4)
assert.deepEqual(
visibleSteps.map((step) => step.label),
['直属领导审批', '财务审批', '待付款', '归档入账']
)
assert.equal(visibleSteps[1].current, true)
})
test('workbench summary builds real user notifications and progress from requests', () => {
const summary = buildWorkbenchSummary(
[
{
id: 'BX-001',
claimId: 'claim-1',
claimNo: 'BX-001',
person: '张三',
title: '差旅报销',
approvalKey: 'draft',
approvalStatus: '草稿',
status: 'draft',
amount: 1280,
createdAt: '2026-06-01T10:00:00+08:00',
updatedAt: '2026-06-01T10:10:00+08:00',
progressSteps: [
buildStep('创建单据', 0, 1),
buildStep('待提交', 1, 1),
buildStep('直属领导审批', 2, 1),
buildStep('财务审批', 3, 1),
buildStep('待付款', 4, 1)
]
},
{
id: 'BX-002',
claimId: 'claim-2',
claimNo: 'BX-002',
person: '李四',
title: '他人单据',
approvalKey: 'draft',
amount: 800,
progressSteps: []
}
],
currentUser
)
assert.equal(summary.todoItems.length, 1)
assert.equal(summary.todoItems[0].target.id, 'claim-1')
assert.equal(summary.progressItems.length, 1)
assert.deepEqual(
summary.progressItems[0].steps.map((step) => step.label),
['创建单据', '待提交', '直属领导审批', '财务审批']
)
assert.equal(summary.notifications.length, 1)
assert.equal(summary.unreadNotificationCount, 1)
})