feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
@@ -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/)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
|
||||
53
web/tests/finance-dashboard-ranking.test.mjs
Normal file
53
web/tests/finance-dashboard-ranking.test.mjs
Normal 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/)
|
||||
})
|
||||
@@ -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;/)
|
||||
|
||||
@@ -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"/)
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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, /当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。/)
|
||||
})
|
||||
|
||||
80
web/tests/workbench-summary.test.mjs
Normal file
80
web/tests/workbench-summary.test.mjs
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user