feat: 新增风险图谱算法与系统仪表盘及操作反馈体系
后端新增风险图谱算法模块、风险观察与反馈服务、规则 DSL 校验器和可解释性引擎,完善系统仪表盘和财务仪表盘统计, 优化 agent 运行和编排执行链路,清理旧开发文档,前端新增 系统趋势、负载热力图等多种仪表盘图表组件,完善操作反馈 对话框和工作台日期选择器,优化报销创建和审批详情交互, 补充单元测试覆盖。
This commit is contained in:
@@ -3,12 +3,20 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildOperationFeedbackPayload,
|
||||
normalizeOperationFeedbackContext
|
||||
} from '../src/composables/useOperationFeedback.js'
|
||||
|
||||
import {
|
||||
SESSION_TYPE_APPLICATION,
|
||||
SESSION_TYPE_EXPENSE,
|
||||
SESSION_TYPE_KNOWLEDGE,
|
||||
buildMessageMeta,
|
||||
buildWelcomeInsight,
|
||||
buildWelcomeMessage
|
||||
buildWelcomeMessage,
|
||||
createMessage,
|
||||
filterVisibleMessageMeta
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
|
||||
const appShellRouteView = readFileSync(
|
||||
@@ -23,10 +31,26 @@ const assistantScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const assistantSubmitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const assistantTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageItemTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const chatViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/ChatView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const operationFeedbackInlineTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/OperationFeedbackInlineCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('application and reimbursement entries open the same financial assistant modal', () => {
|
||||
assert.match(appShellRouteView, /<TravelReimbursementCreateView[\s\S]*:entry-source="smartEntryContext\.source"/)
|
||||
@@ -45,7 +69,8 @@ test('application entry keeps its own assistant source without creating a separa
|
||||
})
|
||||
|
||||
test('financial assistant toolbar renders four isolated assistant sessions', () => {
|
||||
assert.match(assistantScript, /ASSISTANT_SESSION_MODE_OPTIONS\.map/)
|
||||
assert.match(assistantScript, /filterAssistantSessionModes\(ASSISTANT_SESSION_MODE_OPTIONS, currentUser\.value\)/)
|
||||
assert.match(assistantScript, /visibleModes\.map/)
|
||||
assert.match(assistantScript, /targetSessionType:\s*mode\.key/)
|
||||
assert.match(assistantScript, /active:\s*mode\.key === activeSessionType\.value/)
|
||||
assert.match(assistantTemplate, /:class="\{ active: shortcut\.active \}"/)
|
||||
@@ -79,3 +104,88 @@ test('financial assistant welcome copy differentiates application intent from re
|
||||
assert.equal(applicationInsight.metricValue, '申请助手')
|
||||
assert.equal(applicationInsight.title, '申请助手')
|
||||
})
|
||||
|
||||
test('assistant message meta hides internal routing and permission chips', () => {
|
||||
const meta = buildMessageMeta(
|
||||
{
|
||||
selected_agent: 'user_agent',
|
||||
permission_level: 'draft_write',
|
||||
run_id: 'run-001',
|
||||
trace_summary: {
|
||||
tool_count: 3,
|
||||
degraded: true
|
||||
},
|
||||
requires_confirmation: true
|
||||
},
|
||||
['invoice.pdf']
|
||||
)
|
||||
|
||||
assert.deepEqual(meta, ['已降级', '待确认', '附件: 1'])
|
||||
assert.deepEqual(
|
||||
filterVisibleMessageMeta(['Agent: user_agent', '权限: draft_write', 'Run: run-001', '工具: 3', '等待确认']),
|
||||
['等待确认']
|
||||
)
|
||||
assert.deepEqual(
|
||||
createMessage('assistant', '测试', [], { meta: ['Agent: user_agent', '权限: draft_write', '处理中'] }).meta,
|
||||
['处理中']
|
||||
)
|
||||
assert.doesNotMatch(messageItemTemplate, /message-meta-row|message-meta-chip/)
|
||||
assert.doesNotMatch(chatViewTemplate, /agent-meta-row|agent-meta-chip/)
|
||||
})
|
||||
|
||||
test('assistant operation feedback is inline and persists run context', () => {
|
||||
assert.doesNotMatch(appShellRouteView, /<OperationFeedbackDialog/)
|
||||
assert.doesNotMatch(appShellRouteView, /@operation-completed="handleOperationCompleted"/)
|
||||
assert.doesNotMatch(appShellComposable, /useOperationFeedback/)
|
||||
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /class="message-feedback-bubble"/)
|
||||
assert.match(messageItemTemplate, /:submitted="Boolean\(message\.operationFeedback\?\.submitted\)"/)
|
||||
assert.match(messageItemTemplate, /:submitted-rating="Number\(message\.operationFeedback\?\.rating \|\| 0\)"/)
|
||||
assert.match(assistantScript, /emits:\s*\['close', 'draft-saved', 'request-updated'\]/)
|
||||
assert.match(appShellRouteView, /@request-updated="handleRequestUpdated"/)
|
||||
assert.match(assistantScript, /function submitOperationFeedbackForMessage/)
|
||||
assert.match(assistantScript, /createOperationFeedback/)
|
||||
assert.match(assistantScript, /normalizeOperationFeedbackContext/)
|
||||
assert.match(assistantScript, /&& !feedback\.dismissed/)
|
||||
assert.doesNotMatch(assistantScript, /&& !feedback\.submitted/)
|
||||
assert.match(assistantScript, /submitted:\s*true/)
|
||||
assert.match(assistantScript, /dismissed:\s*false/)
|
||||
assert.doesNotMatch(assistantScript, /emit\('operation-completed'/)
|
||||
assert.match(assistantSubmitComposerScript, /emitOperationCompleted\?\.\(payload/)
|
||||
assert.match(assistantSubmitComposerScript, /operationFeedback:\s*buildOperationFeedbackState/)
|
||||
assert.match(assistantSubmitComposerScript, /rating:\s*0/)
|
||||
assert.match(operationFeedbackInlineTemplate, /v-for="option in ratingOptions"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /is-submitted/)
|
||||
assert.match(operationFeedbackInlineTemplate, /submittedRating/)
|
||||
assert.match(operationFeedbackInlineTemplate, /感谢您的反馈。谢谢/)
|
||||
assert.match(operationFeedbackInlineTemplate, /busy \|\| submitted/)
|
||||
assert.match(operationFeedbackInlineTemplate, /role="radiogroup"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /handleRatingKeydown/)
|
||||
assert.match(operationFeedbackInlineTemplate, /operation-feedback-stars/)
|
||||
assert.match(operationFeedbackInlineTemplate, /score > 3/)
|
||||
assert.match(operationFeedbackInlineTemplate, /v-if="showReasonInput"/)
|
||||
assert.match(operationFeedbackInlineTemplate, /稍后/)
|
||||
|
||||
const context = normalizeOperationFeedbackContext(
|
||||
{
|
||||
run_id: 'run-001',
|
||||
conversation_id: 'conv-001',
|
||||
selected_agent: 'user_agent',
|
||||
session_type: 'application',
|
||||
operation_status: 'succeeded',
|
||||
route_reason: 'model_route',
|
||||
result: { answer: '处理完成' }
|
||||
},
|
||||
{ username: 'wenjing.li' }
|
||||
)
|
||||
const payload = buildOperationFeedbackPayload(context, { rating: 2, reason: '识别错了' })
|
||||
|
||||
assert.equal(context.runId, 'run-001')
|
||||
assert.equal(context.userId, 'wenjing.li')
|
||||
assert.equal(payload.run_id, 'run-001')
|
||||
assert.equal(payload.conversation_id, 'conv-001')
|
||||
assert.equal(payload.agent, 'user_agent')
|
||||
assert.equal(payload.rating, 2)
|
||||
assert.equal(payload.reason, '识别错了')
|
||||
assert.equal(payload.context_json.low_rating, true)
|
||||
})
|
||||
|
||||
31
web/tests/app-shell-route-loading.test.mjs
Normal file
31
web/tests/app-shell-route-loading.test.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const shell = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const router = readFileSync(fileURLToPath(new URL('../src/router/index.js', import.meta.url)), 'utf8')
|
||||
|
||||
test('app shell main route views are eagerly imported', () => {
|
||||
assert.doesNotMatch(shell, /defineAsyncRouteView/)
|
||||
assert.doesNotMatch(shell, /defineAsyncComponent/)
|
||||
assert.doesNotMatch(shell, /loadingComponent:/)
|
||||
assert.doesNotMatch(shell, /\u9875\u9762\u5207\u6362\u4e2d/)
|
||||
assert.doesNotMatch(shell, /floating:\s*true/)
|
||||
assert.doesNotMatch(shell, /blocking:\s*true/)
|
||||
assert.match(shell, /import AuditView from '\.\/AuditView\.vue'/)
|
||||
assert.match(shell, /import DigitalEmployeesView from '\.\/DigitalEmployeesView\.vue'/)
|
||||
assert.match(shell, /import EmployeeManagementView from '\.\/EmployeeManagementView\.vue'/)
|
||||
assert.match(shell, /import DocumentsCenterView from '\.\/DocumentsCenterView\.vue'/)
|
||||
assert.match(shell, /import BudgetCenterView from '\.\/BudgetCenterView\.vue'/)
|
||||
})
|
||||
|
||||
test('top-level app routes are eagerly imported', () => {
|
||||
assert.doesNotMatch(router, /\(\)\s*=>\s*import\(/)
|
||||
assert.match(router, /import AppShellRouteView from '\.\.\/views\/AppShellRouteView\.vue'/)
|
||||
assert.match(router, /import LoginRouteView from '\.\.\/views\/LoginRouteView\.vue'/)
|
||||
assert.match(router, /import SetupRouteView from '\.\.\/views\/SetupRouteView\.vue'/)
|
||||
})
|
||||
@@ -89,10 +89,15 @@ test('saving a draft keeps the financial assistant open for continued work', ()
|
||||
)?.[0]
|
||||
|
||||
assert.ok(handleDraftSavedBlock)
|
||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*smartEntryOpen\.value = false/)
|
||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
|
||||
assert.match(handleDraftSavedBlock, /if \(status === 'submitted'\) \{[\s\S]*if \(isApplicationDocument\) \{[\s\S]*return/)
|
||||
assert.match(handleDraftSavedBlock, /smartEntryOpen\.value = false[\s\S]*router\.push\(\{ name: 'app-documents' \}\)/)
|
||||
assert.match(handleDraftSavedBlock, /return[\s\S]*单据已保存为草稿,可继续上传票据或补充信息。/)
|
||||
|
||||
const applicationSubmittedIndex = handleDraftSavedBlock.indexOf('if (isApplicationDocument)')
|
||||
const applicationSubmittedReturnIndex = handleDraftSavedBlock.indexOf('return', applicationSubmittedIndex)
|
||||
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', applicationSubmittedIndex) > applicationSubmittedReturnIndex, true)
|
||||
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", applicationSubmittedIndex) > applicationSubmittedReturnIndex, true)
|
||||
|
||||
const draftSuccessIndex = handleDraftSavedBlock.indexOf('单据已保存为草稿,可继续上传票据或补充信息。')
|
||||
assert.equal(handleDraftSavedBlock.indexOf('smartEntryOpen.value = false', draftSuccessIndex), -1)
|
||||
assert.equal(handleDraftSavedBlock.indexOf("router.push({ name: 'app-documents' })", draftSuccessIndex), -1)
|
||||
|
||||
@@ -14,6 +14,14 @@ test('app route guard allows stale healthy state when health check times out', (
|
||||
assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/)
|
||||
})
|
||||
|
||||
test('authenticated in-app navigation does not wait for backend health check', () => {
|
||||
assert.match(routerScript, /function isAuthenticatedAppNavigation\(to, from\)/)
|
||||
assert.match(
|
||||
routerScript,
|
||||
/if \(isAuthenticatedAppNavigation\(to, from\)\) \{[\s\S]*scheduleBackgroundBackendHealthCheck\(\)[\s\S]*return true[\s\S]*\}/
|
||||
)
|
||||
})
|
||||
|
||||
test('backend health timeout does not block app rendering when stale fallback is allowed', async () => {
|
||||
const originalFetch = global.fetch
|
||||
|
||||
|
||||
82
web/tests/digital-employee-work-record-products.test.mjs
Normal file
82
web/tests/digital-employee-work-record-products.test.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
extractWorkRecordToolSummary,
|
||||
resolveWorkRecordModuleLabel,
|
||||
resolveWorkRecordProductKind,
|
||||
resolveWorkRecordTaskType,
|
||||
resolveWorkRecordTitle
|
||||
} from '../src/views/scripts/digitalEmployeeWorkRecordsModel.js'
|
||||
|
||||
const runProductsComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/audit/DigitalEmployeeRunProducts.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const digitalEmployeeDetailComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/audit/AuditDigitalEmployeeDetail.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('digital employee risk graph run resolves structured product metadata', () => {
|
||||
const run = {
|
||||
route_json: {
|
||||
task_code: 'task.hermes.global_risk_scan',
|
||||
task_name: '财务风险图谱巡检'
|
||||
},
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'digital_employee.financial_risk_graph.scan',
|
||||
request_json: { task_type: 'global_risk_scan' },
|
||||
response_json: {
|
||||
scanned_claim_count: 3,
|
||||
risk_observation_count: 2,
|
||||
graph_node_count: 7,
|
||||
graph_edge_count: 6
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert.equal(resolveWorkRecordTaskType(run), 'global_risk_scan')
|
||||
assert.equal(resolveWorkRecordProductKind(run), 'risk_graph')
|
||||
assert.equal(resolveWorkRecordModuleLabel(run), '财务风险图谱巡检')
|
||||
assert.equal(resolveWorkRecordTitle(run), '财务风险图谱巡检')
|
||||
assert.equal(extractWorkRecordToolSummary(run).risk_observation_count, 2)
|
||||
})
|
||||
|
||||
test('digital employee profile run resolves from tool request when route is sparse', () => {
|
||||
const run = {
|
||||
route_json: { selected_agent: 'hermes' },
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'digital_employee.employee_behavior_profile.scan',
|
||||
request_json: { task_type: 'employee_behavior_profile_scan' },
|
||||
response_json: {
|
||||
target_employee_count: 4,
|
||||
snapshot_count: 16,
|
||||
high_attention_employee_count: 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert.equal(resolveWorkRecordTaskType(run), 'employee_behavior_profile_scan')
|
||||
assert.equal(resolveWorkRecordProductKind(run), 'employee_profile')
|
||||
assert.equal(resolveWorkRecordModuleLabel(run), '员工行为画像巡检')
|
||||
assert.equal(extractWorkRecordToolSummary(run).snapshot_count, 16)
|
||||
})
|
||||
|
||||
test('digital employee work record product supports scoped observation expansion', () => {
|
||||
assert.match(runProductsComponent, /activeObservationKey/)
|
||||
assert.match(runProductsComponent, /toggleObservation/)
|
||||
assert.match(runProductsComponent, /异常关系/)
|
||||
assert.doesNotMatch(runProductsComponent, /KnowledgeIngestGraphView/)
|
||||
})
|
||||
|
||||
test('digital employee skill detail does not render knowledge graph component', () => {
|
||||
assert.doesNotMatch(digitalEmployeeDetailComponent, /KnowledgeIngestGraphView/)
|
||||
assert.doesNotMatch(digitalEmployeeDetailComponent, /LightRAG 知识图谱/)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
buildApplicationPreviewFooterMessage,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
createMessage as createConversationMessage,
|
||||
hasMeaningfulSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
@@ -42,6 +44,10 @@ const messageItemStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const applicationMessageStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-application.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const conversationModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -50,6 +56,38 @@ const previewEditorScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const flowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function createFlowHarness() {
|
||||
return useTravelReimbursementFlow({
|
||||
activeSessionType: ref('application'),
|
||||
reviewDrawerMode: ref(''),
|
||||
insightPanelCollapsed: ref(true),
|
||||
isKnowledgeSession: ref(false),
|
||||
fetchAgentRunDetail: async () => null,
|
||||
buildLocalIntentPreview: () => '本地意图预览',
|
||||
buildLocalExtractionProgressMessages: () => ['正在抽取信息'],
|
||||
summarizeSemanticIntentDetail: () => '模型已完成意图识别',
|
||||
summarizeSemanticParseDetail: () => '模型已完成信息抽取',
|
||||
SCENARIO_LABELS: {},
|
||||
INTENT_LABELS: {},
|
||||
EXPENSE_TYPE_LABELS: {},
|
||||
FLOW_STEP_FALLBACKS: {
|
||||
intent: { title: '意图识别', tool: 'SemanticRouter', runningText: '正在识别业务意图...', completedText: '已识别业务意图' },
|
||||
extraction: { title: '信息抽取', tool: 'SemanticExtractor', runningText: '正在抽取关键信息...', completedText: '已抽取关键信息' },
|
||||
'application-submit-success': { title: '申请单提交成功', tool: 'ApplicationSubmit', runningText: '正在提交申请单...', completedText: '申请单提交成功' }
|
||||
},
|
||||
REVIEW_DRAWER_MODE_FLOW: 'flow',
|
||||
REVIEW_DRAWER_MODE_REVIEW: 'review',
|
||||
FLOW_STEP_STATUS_PENDING: 'pending',
|
||||
FLOW_STEP_STATUS_RUNNING: 'running',
|
||||
FLOW_STEP_STATUS_COMPLETED: 'completed',
|
||||
FLOW_STEP_STATUS_FAILED: 'failed'
|
||||
})
|
||||
}
|
||||
|
||||
test('application intent uses local preview instead of immediate orchestrator call', () => {
|
||||
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差3天,高铁,预计金额2358元'
|
||||
@@ -119,8 +157,11 @@ test('application preview cleans empty time labels and keeps only business reaso
|
||||
const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
}, {
|
||||
today: '2026-05-29'
|
||||
})
|
||||
|
||||
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
|
||||
assert.equal(preview.fields.location, '九江')
|
||||
assert.equal(preview.fields.days, '3天')
|
||||
assert.equal(preview.fields.reason, '服务美团业务部署')
|
||||
@@ -130,7 +171,7 @@ test('application preview cleans empty time labels and keeps only business reaso
|
||||
|
||||
test('application preview can be refined by ontology model extraction', () => {
|
||||
const rawText = '发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' }, { today: '2026-05-29' })
|
||||
const refinedPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
{
|
||||
@@ -153,11 +194,23 @@ test('application preview can be refined by ontology model extraction', () => {
|
||||
assert.equal(refinedPreview.parseStrategy, 'llm_primary')
|
||||
assert.equal(refinedPreview.modelReviewStatus, 'completed')
|
||||
assert.equal(refinedPreview.fields.applicationType, '差旅费用申请')
|
||||
assert.equal(refinedPreview.fields.time, '')
|
||||
assert.equal(refinedPreview.fields.time, '2026-05-29 至 2026-05-31')
|
||||
assert.equal(refinedPreview.fields.reason, '服务美团业务部署')
|
||||
assert.equal(refinedPreview.fields.transportMode, '火车')
|
||||
})
|
||||
|
||||
test('application preview precomputes a date range from today when only days are provided', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'去北京出差3天,支撑国网仿生产环境部署,飞机,预计费用12000元',
|
||||
{ name: '李文静', grade: 'P5' },
|
||||
{ today: '2026-05-29' }
|
||||
)
|
||||
|
||||
assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31')
|
||||
assert.equal(preview.fields.days, '3天')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
})
|
||||
|
||||
test('application preview keeps rule fallback distinct from model reviewed result', () => {
|
||||
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差3天,火车,预计费用1800元'
|
||||
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
|
||||
@@ -279,8 +332,22 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /补齐后我再帮您提交申请。/)
|
||||
assert.match(messageItemTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/)
|
||||
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
|
||||
assert.match(messageItemTemplate, /application-preview-date-chip/)
|
||||
assert.match(messageItemTemplate, /申请单据已生成/)
|
||||
assert.match(messageItemTemplate, /application-draft-head/)
|
||||
assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/)
|
||||
assert.match(messageItemTemplate, /'is-primary': item\.label === '单号'/)
|
||||
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
|
||||
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
|
||||
assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/)
|
||||
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
|
||||
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
|
||||
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
|
||||
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
|
||||
assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.singleDate"/)
|
||||
assert.match(messageItemTemplate, /application-preview-select/)
|
||||
assert.match(messageItemTemplate, /resolveApplicationPreviewEditorOptions/)
|
||||
assert.match(messageItemTemplate, /row\.editable && !ui\.isApplicationPreviewEditing\(message, row\.key\).*ui\.openApplicationPreviewEditor\(message, row\.key, row\.value\)"/)
|
||||
@@ -291,13 +358,26 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /openApplicationPreviewEditor/)
|
||||
assert.match(messageItemTemplate, /commitApplicationPreviewEditor/)
|
||||
assert.match(createViewScript, /resolveApplicationPreviewMissingFields/)
|
||||
assert.match(createViewScript, /function applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
|
||||
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
|
||||
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
|
||||
assert.doesNotMatch(createViewTemplate, /@click="applyComposerDateSelection"/)
|
||||
|
||||
assert.match(previewEditorScript, /normalizeApplicationPreview/)
|
||||
assert.match(previewEditorScript, /APPLICATION_TRANSPORT_MODE_OPTIONS/)
|
||||
assert.match(previewEditorScript, /getTodayDateValue/)
|
||||
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(previewEditorScript, /targetRow\.editable === false/)
|
||||
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
|
||||
assert.match(previewEditorScript, /fieldKey === 'time'\) return 'date'/)
|
||||
assert.match(previewEditorScript, /commitApplicationPreviewDateEditor/)
|
||||
|
||||
assert.match(messageItemStyles, /@import "\.\/travel-reimbursement-message-application\.css";/)
|
||||
assert.match(messageItemStyles, /\.application-preview-row\.missing \{[\s\S]*--theme-primary-rgb/)
|
||||
assert.match(messageItemStyles, /\.application-preview-table \{[\s\S]*border: 1px solid #d7e4f2;[\s\S]*background: #ffffff;/)
|
||||
assert.match(messageItemStyles, /\.application-preview-row \{[\s\S]*grid-template-columns: 108px minmax\(0, 1fr\);/)
|
||||
@@ -307,6 +387,65 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-action-link\) \{[\s\S]*text-decoration: underline;/)
|
||||
assert.match(messageItemStyles, /\.application-preview-footer-missing \{[\s\S]*margin-top: 48px;[\s\S]*background: transparent;/)
|
||||
assert.match(messageItemStyles, /\.application-preview-missing-chip \{[\s\S]*background: rgba\(var\(--theme-primary-rgb/)
|
||||
assert.doesNotMatch(applicationMessageStyles, /\.application-date-editor-layer/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-preview \.application-draft-head \{[\s\S]*grid-template-columns: 36px minmax\(0, 1fr\) auto;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief \{[\s\S]*border: 1px solid #d7e4f2;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
|
||||
|
||||
assert.match(flowScript, /application-submit-success/)
|
||||
assert.match(flowScript, /function shouldHideToolCall/)
|
||||
assert.match(flowScript, /semantic_ontology/)
|
||||
assert.match(flowScript, /return null/)
|
||||
assert.match(flowScript, /申请单提交成功/)
|
||||
assert.match(flowScript, /function resolveDurationFromFields/)
|
||||
assert.match(flowScript, /function resolveStartedTimestamp/)
|
||||
assert.match(flowScript, /function resolveFinishedTimestamp/)
|
||||
assert.match(flowScript, /syntheticTiming/)
|
||||
assert.match(flowScript, /refreshCompleted/)
|
||||
})
|
||||
|
||||
test('flow panel durations use backend timing instead of local preview delay', () => {
|
||||
const flow = createFlowHarness()
|
||||
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
|
||||
flow.startFlowStep('intent', '正在识别业务意图...')
|
||||
flow.completeFlowStep('intent', '本地预览完成', 80)
|
||||
flow.startFlowStep('extraction', '正在抽取关键信息...')
|
||||
flow.completeFlowStep('extraction', '本地抽取完成', 90)
|
||||
flow.startFlowStep('application-submit-success', {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
detail: '正在提交申请单...'
|
||||
})
|
||||
flow.completeFlowStep('application-submit-success', '本地提交完成', 100)
|
||||
|
||||
flow.mergeFlowRunDetail({
|
||||
started_at: '2026-05-29T00:00:00.000Z',
|
||||
finished_at: '2026-05-29T00:00:05.000Z',
|
||||
status: 'succeeded',
|
||||
semantic_parse: {},
|
||||
ontology_json: {},
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'submit-1',
|
||||
run_id: 'run-1',
|
||||
tool_type: 'application',
|
||||
tool_name: 'application.submit',
|
||||
request_json: {},
|
||||
response_json: { status: 'submitted', draft_payload: { status: 'submitted' } },
|
||||
status: 'succeeded',
|
||||
duration_ms: 2360,
|
||||
created_at: '2026-05-29T00:00:04.000Z'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const durationByKey = Object.fromEntries(flow.flowSteps.value.map((step) => [step.key, step.durationMs]))
|
||||
assert.equal(durationByKey.intent, 1400)
|
||||
assert.equal(durationByKey.extraction, 2600)
|
||||
assert.equal(durationByKey['application-submit-success'], 2360)
|
||||
assert.equal(flow.flowTotalDurationText.value, '5.0s')
|
||||
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: 0 }), '--')
|
||||
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--')
|
||||
})
|
||||
|
||||
test('assistant markdown tables render with component-scoped table styling', () => {
|
||||
|
||||
@@ -29,7 +29,7 @@ test('expense application fields use labeled reason and filter resolved missing
|
||||
{ name: '申请员工', departmentName: '交付部' }
|
||||
)
|
||||
|
||||
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-28')
|
||||
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-27')
|
||||
assert.equal(fields.location, '上海')
|
||||
assert.equal(fields.reason, '支撑国网服务器部署')
|
||||
assert.deepEqual(
|
||||
@@ -65,8 +65,8 @@ test('expense application expands a single selected date with natural days', ()
|
||||
'去上海出差3天,支撑国网服务器部署'
|
||||
].join('\n')
|
||||
|
||||
assert.equal(expandApplicationTimeWithDays('2026-05-25', 3), '2026-05-25 至 2026-05-28')
|
||||
assert.equal(resolveApplicationTimeRange({ time_range: {} }, prompt), '2026-05-25 至 2026-05-28')
|
||||
assert.equal(expandApplicationTimeWithDays('2026-05-25', 3), '2026-05-25 至 2026-05-27')
|
||||
assert.equal(resolveApplicationTimeRange({ time_range: {} }, prompt), '2026-05-25 至 2026-05-27')
|
||||
})
|
||||
|
||||
test('expense application keeps explicit time range before applying days', () => {
|
||||
|
||||
@@ -3,6 +3,12 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { assistantCapabilities } from '../src/data/personalWorkbench.js'
|
||||
import {
|
||||
buildWorkbenchCapabilityAssistantPayload,
|
||||
resolveWorkbenchCapabilityAssistantEntry
|
||||
} from '../src/utils/personalWorkbenchAssistantEntry.js'
|
||||
|
||||
const workbench = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -11,8 +17,50 @@ const workbench = readFileSync(
|
||||
test('workbench assistant greets the current employee without the old helper tag', () => {
|
||||
assert.doesNotMatch(workbench, /assistant-tag/)
|
||||
assert.doesNotMatch(workbench, /AI 报销助手/)
|
||||
assert.match(workbench, /嗨,\{\{ assistantGreetingName \}\},描述您想做的事,AI 会直接帮您处理/)
|
||||
assert.match(workbench, /我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作/)
|
||||
assert.match(workbench, /const assistantGreetingName = computed/)
|
||||
assert.match(workbench, /嗨,\{\{ displayUserName \}\},我是您的 <span>AI 费用助手<\/span>/)
|
||||
assert.match(workbench, /placeholder="请输入费用申请、报销问题、预算查询或制度问答\.\.\."/)
|
||||
assert.match(workbench, /const displayUserName = computed/)
|
||||
assert.match(workbench, /user\.name/)
|
||||
})
|
||||
|
||||
test('workbench capability cards open assistant without injecting canned prompts', () => {
|
||||
assert.match(workbench, /@click="openCapabilityAssistant\(item\)"/)
|
||||
assert.doesNotMatch(workbench, /openPromptAssistant\(item\.prompt\)/)
|
||||
|
||||
for (const item of assistantCapabilities) {
|
||||
const payload = buildWorkbenchCapabilityAssistantPayload(item, {
|
||||
prompt: '',
|
||||
source: 'workbench',
|
||||
files: []
|
||||
})
|
||||
|
||||
assert.equal(payload.prompt, '')
|
||||
assert.equal(payload.conversation, null)
|
||||
assert.notEqual(payload.prompt, item.prompt)
|
||||
assert.ok(resolveWorkbenchCapabilityAssistantEntry(item).sessionType)
|
||||
}
|
||||
})
|
||||
|
||||
test('workbench capability cards keep user-entered context only', () => {
|
||||
const [expenseApplication] = assistantCapabilities
|
||||
const files = [{ name: 'invoice.pdf' }]
|
||||
const payload = buildWorkbenchCapabilityAssistantPayload(expenseApplication, {
|
||||
prompt: '我需要申请下周出差费用',
|
||||
source: 'workbench',
|
||||
files
|
||||
})
|
||||
|
||||
assert.equal(payload.prompt, '我需要申请下周出差费用')
|
||||
assert.equal(payload.source, 'application')
|
||||
assert.equal(payload.sessionType, 'application')
|
||||
assert.equal(payload.files, files)
|
||||
})
|
||||
|
||||
test('workbench submit shows intent recognition feedback before assistant opens', () => {
|
||||
assert.match(workbench, /class="assistant-intent-status"/)
|
||||
assert.match(workbench, /aria-live="polite"/)
|
||||
assert.match(workbench, /正在识别意图,准备进入对应助手/)
|
||||
assert.match(workbench, /startPendingAction\('intent'\)/)
|
||||
assert.match(workbench, /if \(open\) \{\s*clearPendingAction\(\)/)
|
||||
assert.match(workbench, /:readonly="isComposerPending"/)
|
||||
})
|
||||
|
||||
@@ -13,10 +13,23 @@ function testReceiptFolderViewSurface() {
|
||||
|
||||
assert.match(view, /未关联票据/)
|
||||
assert.match(view, /已关联票据/)
|
||||
assert.match(view, /label: '全部'/)
|
||||
assert.match(view, /一键关联票据/)
|
||||
assert.match(view, /基本票据信息/)
|
||||
assert.match(view, /票据关键字段/)
|
||||
assert.match(view, /原始文件/)
|
||||
assert.match(view, /返回列表/)
|
||||
assert.match(view, /keyReceiptFields/)
|
||||
assert.match(view, /editableOtherFields/)
|
||||
assert.match(view, /class="receipt-key-grid"/)
|
||||
assert.match(view, /class="receipt-other-collapse"/)
|
||||
assert.match(view, /class="receipt-other-scroll"/)
|
||||
assert.match(view, /其他信息/)
|
||||
assert.match(view, /ElCollapse/)
|
||||
assert.doesNotMatch(view, /新增字段/)
|
||||
assert.match(view, /const isTrainTicket = computed/)
|
||||
assert.doesNotMatch(view, /打开源文件/)
|
||||
assert.doesNotMatch(view, /openSourceFile/)
|
||||
assert.match(view, /返回票据夹/)
|
||||
assert.doesNotMatch(view, /返回列表/)
|
||||
assert.match(view, /删除票据/)
|
||||
assert.match(view, /ElCheckboxGroup/)
|
||||
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
|
||||
@@ -41,11 +54,16 @@ function testReceiptFolderServiceContract() {
|
||||
|
||||
function testAppShellWiresReceiptFolder() {
|
||||
const shell = readProjectFile('web/src/views/AppShellRouteView.vue')
|
||||
const topBar = readProjectFile('web/src/components/layout/TopBar.vue')
|
||||
|
||||
assert.match(shell, /activeView === 'receiptFolder'/)
|
||||
assert.match(shell, /ReceiptFolderView/)
|
||||
assert.match(shell, /@open-assistant="openSmartEntry"/)
|
||||
assert.match(shell, /@detail-open-change="receiptFolderDetailOpen = \$event"/)
|
||||
assert.match(shell, /@detail-topbar-change="detailTopBarPayload = \$event"/)
|
||||
assert.match(shell, /receipt-folder-workarea/)
|
||||
assert.match(topBar, /receiptFolder/)
|
||||
assert.match(topBar, /eyebrowLabel/)
|
||||
}
|
||||
|
||||
function testSharedDocumentListStyleReuse() {
|
||||
@@ -64,11 +82,61 @@ function testSharedDocumentListStyleReuse() {
|
||||
assert.doesNotMatch(receiptStyles, /\.list-foot\b/)
|
||||
}
|
||||
|
||||
function testReceiptFolderDetailLayoutAdjustments() {
|
||||
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||
const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css')
|
||||
const fieldModel = readProjectFile('web/src/views/scripts/receiptFolderDetailFields.js')
|
||||
|
||||
assert.match(receiptView, /showStatusColumn/)
|
||||
assert.match(receiptView, /<col v-if="showStatusColumn" class="col-status">/)
|
||||
assert.match(receiptView, /<th v-if="showStatusColumn">/)
|
||||
assert.match(receiptView, /<th>票据日期<\/th>/)
|
||||
assert.match(receiptView, /<td>{{ row\.document_date \|\| '待补充' }}<\/td>/)
|
||||
assert.match(receiptView, /<td>\s*<strong class="doc-id">/)
|
||||
assert.match(receiptView, /<td v-if="showStatusColumn">\s*<span class="status-tag"/)
|
||||
assert.match(receiptView, /const activeStatus = ref\('all'\)/)
|
||||
assert.match(receiptView, /import EnterpriseDetailCard/)
|
||||
assert.match(receiptView, /import EnterpriseDetailPage/)
|
||||
assert.match(receiptView, /<EnterpriseDetailPage/)
|
||||
assert.match(receiptView, /variant="receipt-folder-detail"/)
|
||||
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-basic-panel"/)
|
||||
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-preview-panel"/)
|
||||
assert.match(receiptView, /createReceiptDetailFieldModel/)
|
||||
assert.match(receiptView, /buildDetailPayload\(\)/)
|
||||
assert.match(receiptView, /receiptDetailSubtitle/)
|
||||
assert.match(receiptView, /receiptDetailTopBarPayload/)
|
||||
assert.match(receiptView, /eyebrow: '票据详情'/)
|
||||
assert.match(receiptView, /detail-topbar-change/)
|
||||
assert.doesNotMatch(receiptView, /<article v-else class="receipt-folder-detail/)
|
||||
assert.doesNotMatch(receiptView, /class="back-btn"/)
|
||||
assert.doesNotMatch(receiptView, /receipt-detail-head/)
|
||||
assert.doesNotMatch(receiptView, /detail-actions receipt-detail-foot/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-list th:first-child/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-grid\)/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-actions\)/)
|
||||
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.enterprise-detail-card \.card-head\)/)
|
||||
assert.match(receiptStyles, /\.receipt-key-grid/)
|
||||
assert.match(receiptStyles, /\.receipt-other-collapse/)
|
||||
assert.match(receiptStyles, /\.receipt-other-scroll/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-detail-head\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.receipt-detail-layout\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.detail-loading\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.back-btn\b/)
|
||||
assert.doesNotMatch(receiptStyles, /\.danger-btn\b/)
|
||||
assert.match(fieldModel, /TRAIN_KEY_FIELD_DEFINITIONS/)
|
||||
assert.match(fieldModel, /label: '发票号码'/)
|
||||
assert.match(fieldModel, /label: '开票日期'/)
|
||||
assert.match(fieldModel, /label: '票价'/)
|
||||
assert.match(fieldModel, /label: '姓名'/)
|
||||
assert.match(fieldModel, /syncEditableFieldsToTopLevel/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
testReceiptFolderViewSurface()
|
||||
testReceiptFolderServiceContract()
|
||||
testAppShellWiresReceiptFolder()
|
||||
testSharedDocumentListStyleReuse()
|
||||
testReceiptFolderDetailLayoutAdjustments()
|
||||
console.log('receipt folder view tests passed')
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ const BUDGET_MANAGER_APPROVAL = '\u9884\u7b97\u7ba1\u7406\u8005\u5ba1\u6279'
|
||||
const APPROVAL_COMPLETED = '\u5ba1\u6279\u5b8c\u6210'
|
||||
const RETURNED = '\u9000\u56de'
|
||||
const WAIT_SUBMIT = '\u5f85\u63d0\u4ea4'
|
||||
const 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 LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
|
||||
@@ -191,6 +194,47 @@ test('approved application claims complete after budget approval', () => {
|
||||
assert.equal(request.progressSteps.find((step) => step.label === '预算管理者审批')?.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]
|
||||
)
|
||||
assert.equal(request.progressSteps.every((step) => step.done), true)
|
||||
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('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()
|
||||
@@ -230,7 +274,7 @@ test('progress steps show approval operator time and current stay duration', ()
|
||||
const firstStep = request.progressSteps[0]
|
||||
|
||||
assert.equal(request.riskSummary, '无')
|
||||
assert.equal(firstStep.label, '创建单据')
|
||||
assert.equal(firstStep.label, LINKED_APPLICATION)
|
||||
assert.equal(leaderStep.time, '李经理通过')
|
||||
assert.match(leaderStep.detail, /2026-05-20/)
|
||||
assert.match(leaderStep.title, /李经理审批通过/)
|
||||
@@ -479,19 +523,48 @@ test('paid reimbursement marks payment progress step as complete', () => {
|
||||
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: '3 天',
|
||||
application_location: '北京',
|
||||
application_amount: '3000',
|
||||
application_time: '2026-05-20T00:00:00.000Z',
|
||||
application_transport_mode: '高铁'
|
||||
}
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const paymentStep = request.progressSteps.find((step) => step.label === '待付款')
|
||||
const paidStep = 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, '待提交', 'AI预审', '直属领导审批', '财务审批', '待付款', 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, '3 天')
|
||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||
})
|
||||
|
||||
test('current direct manager step shows how long the claim has stayed there', () => {
|
||||
|
||||
77
web/tests/risk-observation-dashboard.test.mjs
Normal file
77
web/tests/risk-observation-dashboard.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeRiskObservationDashboard } from '../src/services/riskObservations.js'
|
||||
|
||||
const dashboardComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/dashboard/RiskObservationDashboard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const overviewViewModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useOverviewView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const overviewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/OverviewView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('risk dashboard normalizes amount, distributions, and ranking fields', () => {
|
||||
const dashboard = normalizeRiskObservationDashboard({
|
||||
total_observations: 5,
|
||||
total_amount: 12800,
|
||||
department_distribution: { 风控部: 3 },
|
||||
expense_type_distribution: { travel: 2 },
|
||||
risk_type_distribution: { duplicate_invoice: 2 },
|
||||
supplier_distribution: { 上海差旅供应商: 1 },
|
||||
employee_grade_distribution: { P6: 2 },
|
||||
top_departments: [{ name: '风控部', count: 3, amount: 8800 }],
|
||||
top_employees: [{ name: '风险员工', count: 2, amount: 6200 }],
|
||||
top_suppliers: [{ name: '上海差旅供应商', count: 1, amount: 1200 }],
|
||||
top_expense_types: [{ name: 'travel', count: 2, amount: 4600 }],
|
||||
top_rules: [{ name: 'policy.duplicate_invoice', count: 2, amount: 3000 }]
|
||||
})
|
||||
|
||||
assert.equal(dashboard.totalAmount, 12800)
|
||||
assert.equal(dashboard.departmentDistribution['风控部'], 3)
|
||||
assert.equal(dashboard.expenseTypeDistribution.travel, 2)
|
||||
assert.equal(dashboard.riskTypeDistribution.duplicate_invoice, 2)
|
||||
assert.equal(dashboard.supplierDistribution['上海差旅供应商'], 1)
|
||||
assert.equal(dashboard.employeeGradeDistribution.P6, 2)
|
||||
assert.equal(dashboard.topDepartments[0].amount, 8800)
|
||||
assert.equal(dashboard.topRules[0].name, 'policy.duplicate_invoice')
|
||||
})
|
||||
|
||||
test('risk dashboard renders overview amount and multi-dimension panels', () => {
|
||||
assert.match(overviewViewModel, /label: '新增风险数'/)
|
||||
assert.match(overviewViewModel, /label: '涉及金额'/)
|
||||
assert.match(overviewViewModel, /label: '已确认风险'/)
|
||||
assert.match(overviewViewModel, /label: '误报数量'/)
|
||||
assert.match(dashboardComponent, /业务维度分布/)
|
||||
assert.match(dashboardComponent, /异常排行/)
|
||||
assert.match(dashboardComponent, /departmentDistribution/)
|
||||
assert.match(dashboardComponent, /expenseTypeDistribution/)
|
||||
assert.match(dashboardComponent, /supplierDistribution/)
|
||||
assert.match(dashboardComponent, /employeeGradeDistribution/)
|
||||
assert.match(dashboardComponent, /topDepartments/)
|
||||
assert.match(dashboardComponent, /topEmployees/)
|
||||
assert.match(dashboardComponent, /topSuppliers/)
|
||||
assert.match(dashboardComponent, /topRules/)
|
||||
})
|
||||
|
||||
test('risk dashboard wires window filter to trend, ranking, and cards data source', () => {
|
||||
assert.match(overviewViewModel, /const activeRiskWindowDays = ref\(30\)/)
|
||||
assert.match(overviewViewModel, /windowDays: activeRiskWindowDays\.value/)
|
||||
assert.match(overviewViewModel, /watch\(activeRiskWindowDays/)
|
||||
assert.match(overviewViewModel, /setRiskWindowDays/)
|
||||
assert.match(overviewTemplate, /:window-options="riskWindowOptions"/)
|
||||
assert.match(overviewTemplate, /:active-window-days="activeRiskWindowDays"/)
|
||||
assert.match(overviewTemplate, /@update:window-days="setRiskWindowDays"/)
|
||||
assert.match(dashboardComponent, /EnterpriseSelect/)
|
||||
assert.match(dashboardComponent, /aria-label="风险看板时间窗口"/)
|
||||
assert.match(dashboardComponent, /emit\('update:windowDays', \$event\)/)
|
||||
assert.match(dashboardComponent, /RiskDailyTrendChart/)
|
||||
assert.match(dashboardComponent, /rankingGroups/)
|
||||
})
|
||||
42
web/tests/risk-observation-evidence-card.test.mjs
Normal file
42
web/tests/risk-observation-evidence-card.test.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const component = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/RiskObservationEvidenceCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const styles = readFileSync(
|
||||
fileURLToPath(
|
||||
new URL('../src/assets/styles/components/risk-observation-evidence-card.css', import.meta.url)
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('risk observation evidence card supports switching active observation details', () => {
|
||||
assert.match(component, /当前风险观察详情/)
|
||||
assert.match(component, /const detailRegionId = 'risk-observation-active-detail'/)
|
||||
assert.match(component, /:aria-controls="detailRegionId"/)
|
||||
assert.match(component, /:aria-pressed="isActiveObservation\(item, index\)"/)
|
||||
assert.match(component, /@click="selectObservation\(item, index\)"/)
|
||||
assert.match(component, /const feedbackRows = computed/)
|
||||
assert.match(component, /反馈历史/)
|
||||
assert.match(styles, /\.risk-evidence-current-head/)
|
||||
assert.match(styles, /\.risk-observation-row:focus-visible/)
|
||||
})
|
||||
|
||||
test('risk observation evidence card remains scoped to current claim data', () => {
|
||||
assert.match(component, /fetchClaimRiskObservations\(claimId\)/)
|
||||
assert.doesNotMatch(component, /fetchEmployeeProfile/)
|
||||
assert.doesNotMatch(component, /employeeBehaviorProfile/)
|
||||
assert.doesNotMatch(component, /KnowledgeIngestGraphView/)
|
||||
assert.doesNotMatch(component, /LightRAG/)
|
||||
})
|
||||
|
||||
test('risk observation evidence card keeps empty observation state out of main flow', () => {
|
||||
assert.match(component, /const visible = computed/)
|
||||
assert.match(component, /observations\.value\.length > 0/)
|
||||
assert.match(component, /暂无证据明细。/)
|
||||
assert.match(component, /暂无人工反馈。/)
|
||||
})
|
||||
36
web/tests/rule-digital-list-visual-consistency.test.mjs
Normal file
36
web/tests/rule-digital-list-visual-consistency.test.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const auditAssetList = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/audit/AuditAssetList.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const auditViewStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/audit-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const digitalEmployeeList = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/audit/DigitalEmployeeListPanel.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const digitalEmployeeStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/digital-employees-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('audit rule name column stays left aligned', () => {
|
||||
assert.match(auditAssetList, /<table class="audit-asset-table">/)
|
||||
assert.match(auditViewStyles, /\.audit-asset-table th:first-child,[\s\S]*?text-align: left;/)
|
||||
assert.match(auditViewStyles, /\.skill-name-cell \{[\s\S]*?text-align: left;/)
|
||||
})
|
||||
|
||||
test('digital employee skill name keeps text avatar prefix', () => {
|
||||
assert.match(digitalEmployeeList, /class="digital-skill-cell"/)
|
||||
assert.match(digitalEmployeeList, /class="digital-skill-avatar"/)
|
||||
assert.match(digitalEmployeeStyles, /\.digital-skill-cell \{[\s\S]*?text-align: left;/)
|
||||
assert.match(digitalEmployeeStyles, /\.digital-skill-cell \{[\s\S]*?grid-template-columns: 38px minmax\(0, 1fr\);/)
|
||||
assert.match(digitalEmployeeStyles, /\.digital-skill-avatar \{[\s\S]*?width: 38px;[\s\S]*?border-radius: 11px;/)
|
||||
assert.match(digitalEmployeeStyles, /\.digital-skill-avatar\.blue \{[\s\S]*?linear-gradient\(135deg, #3b82f6, #2563eb\);/)
|
||||
})
|
||||
@@ -11,6 +11,14 @@ const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const composerToolsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementComposerTools.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('composer formats date-picker expense text into readable structured fields', () => {
|
||||
const formatted = buildStructuredComposerSubmitText(
|
||||
@@ -56,6 +64,45 @@ test('composer extracts destination and reason from compact travel text', () =>
|
||||
)
|
||||
})
|
||||
|
||||
test('composer strips raw date picker prefix before building structured display', () => {
|
||||
const formatted = buildStructuredComposerSubmitText(
|
||||
'2026-05-20 至 2026-05-23,去上海支撑上海国电的服务器部署,出差3天',
|
||||
{
|
||||
mode: 'range',
|
||||
start_date: '2026-05-20',
|
||||
end_date: '2026-05-23',
|
||||
business_time: '2026-05-20 至 2026-05-23'
|
||||
}
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
formatted,
|
||||
[
|
||||
'发生时间:2026-05-20 至 2026-05-23',
|
||||
'地点:上海',
|
||||
'事由:支撑上海国电的服务器部署',
|
||||
'天数:3天'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
test('composer date selection shows raw date capsule labels', () => {
|
||||
assert.match(createViewTemplate, /aria-label="选择日期"/)
|
||||
assert.match(createViewTemplate, /aria-label="日期选择"/)
|
||||
assert.match(createViewTemplate, /aria-label="移除日期"/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
|
||||
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
|
||||
assert.doesNotMatch(createViewTemplate, /@click="applyComposerDateSelection"/)
|
||||
assert.doesNotMatch(createViewTemplate, /aria-label="选择业务发生时间"/)
|
||||
assert.doesNotMatch(createViewTemplate, /aria-label="移除业务发生时间"/)
|
||||
assert.doesNotMatch(composerToolsScript, /return `发生时间:\$\{composerSingleDate\.value\}`/)
|
||||
assert.match(composerToolsScript, /return composerSingleDate\.value/)
|
||||
assert.match(composerToolsScript, /return `\$\{composerRangeStartDate\.value\} 至 \$\{composerRangeEndDate\.value\}`/)
|
||||
assert.match(composerToolsScript, /function commitComposerDateSelection/)
|
||||
assert.match(composerToolsScript, /onComposerDateSelection\?\.\(selection\)/)
|
||||
})
|
||||
|
||||
test('composer keeps backend raw text but displays structured user message', () => {
|
||||
assert.match(submitComposerScript, /const rawText = resolveComposerSubmitText\(options\.rawText\)\.trim\(\)/)
|
||||
assert.match(submitComposerScript, /resolveComposerDisplaySubmitText\(rawText\)/)
|
||||
|
||||
@@ -132,6 +132,26 @@ test('assistant session scope guard keeps business boundaries isolated', () => {
|
||||
assert.equal(expenseInApplication.suggestedActions[0].payload.carry_text, '我想报销的士票')
|
||||
|
||||
assert.equal(resolveAssistantScopeGuard('我想发起一笔费用申请', SESSION_TYPE_APPLICATION), null)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('去北京出差3天,支撑国网仿生产环境部署', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||
SESSION_TYPE_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('去国网出差3天,协助仿生产环境部署', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||
SESSION_TYPE_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('下周去上海支撑客户系统上线,预计3天', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||
SESSION_TYPE_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('安排去深圳客户现场验收项目', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||
SESSION_TYPE_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('我要报销去北京出差的费用', SESSION_TYPE_APPLICATION).targetSessionType,
|
||||
SESSION_TYPE_EXPENSE
|
||||
)
|
||||
assert.equal(
|
||||
resolveAssistantScopeGuard('帮我查询待我审核的单据', SESSION_TYPE_EXPENSE).targetSessionType,
|
||||
SESSION_TYPE_APPROVAL
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
buildLocallySyncedReviewPayload,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
isTravelReviewPayload,
|
||||
resolveReviewFooterActions
|
||||
} from '../src/views/scripts/travelReimbursementReviewModel.js'
|
||||
import { useTravelReimbursementAttachments } from '../src/views/scripts/useTravelReimbursementAttachments.js'
|
||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||
|
||||
const createViewTemplate = readFileSync(
|
||||
@@ -407,7 +409,7 @@ test('review drawer save action is disabled while receipt recognition is submitt
|
||||
test('draft creation starts composer attachment persistence after response rendering', () => {
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/void syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\.then\(\(\) => \{\s*persistSessionState\(\)\s*\}\)\s*\.catch\(\(error\) => \{/s
|
||||
/void syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\.then\(\(syncResult\) => \{[\s\S]*persistSessionState\(\)[\s\S]*emitRequestUpdated\?\.\(\{/s
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
submitComposerScript,
|
||||
@@ -422,10 +424,84 @@ test('draft creation starts composer attachment persistence after response rende
|
||||
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
||||
assert.match(
|
||||
attachmentsScript,
|
||||
/const targetItem = nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch/
|
||||
/nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch \|\| emptyFallbackMatch/
|
||||
)
|
||||
})
|
||||
|
||||
test('detail smart-entry receipt sync uploads files to existing empty items and creates a row when needed', async () => {
|
||||
const uploadCalls = []
|
||||
let createCount = 0
|
||||
const claimSnapshots = [
|
||||
{
|
||||
items: [
|
||||
{ id: 'item-empty-1', item_type: 'hotel_ticket', invoice_id: '' },
|
||||
{ id: 'item-persisted', item_type: 'train_ticket', invoice_id: 'claim-1/item-persisted/old.pdf' }
|
||||
]
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{ id: 'item-empty-1', item_type: 'hotel_ticket', invoice_id: '' },
|
||||
{ id: 'item-persisted', item_type: 'train_ticket', invoice_id: 'claim-1/item-persisted/old.pdf' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const attachments = useTravelReimbursementAttachments({
|
||||
isKnowledgeSession: ref(false),
|
||||
reviewFilePreviews: ref([]),
|
||||
linkedRequest: ref({}),
|
||||
draftClaimId: ref('claim-1'),
|
||||
activeReviewPayload: ref(null),
|
||||
reviewInlinePendingFiles: ref([]),
|
||||
reviewInlineForm: ref({}),
|
||||
reviewInlineEditorKey: ref(''),
|
||||
composerUploadIntent: ref(''),
|
||||
submitting: ref(false),
|
||||
reviewActionBusy: ref(false),
|
||||
toast: () => {},
|
||||
fileInputRef: ref(null),
|
||||
createExpenseClaimItem: async () => {
|
||||
createCount += 1
|
||||
return {
|
||||
items: [
|
||||
{ id: 'item-created-1', item_type: 'taxi_receipt', invoice_id: '' }
|
||||
]
|
||||
}
|
||||
},
|
||||
fetchExpenseClaimDetail: async () => claimSnapshots.shift() || { items: [] },
|
||||
fetchExpenseClaimItemAttachmentMeta: async () => null,
|
||||
fetchExpenseClaimAttachmentAsset: async () => new Blob(['preview']),
|
||||
uploadExpenseClaimItemAttachment: async (claimId, itemId, file) => {
|
||||
uploadCalls.push({ claimId, itemId, fileName: file.name })
|
||||
return {}
|
||||
},
|
||||
extractReviewAttachmentNames: () => [],
|
||||
mergeFilesWithLimit: (existing, incoming) => ({ files: [...existing, ...incoming], overflowCount: 0 }),
|
||||
mergeFilePreviews: (existing, incoming) => [...existing, ...incoming],
|
||||
isTemporaryPreviewUrl: () => false,
|
||||
resolveAttachmentPreviewKind: () => '',
|
||||
resolveDocumentPreview: () => null,
|
||||
buildFilePreviews: () => [],
|
||||
buildFileIdentity: (file) => file.name,
|
||||
MAX_ATTACHMENTS: 5,
|
||||
VISIBLE_ATTACHMENT_CHIPS: 3,
|
||||
clearInlineReviewFieldError: () => {}
|
||||
})
|
||||
|
||||
const result = await attachments.syncComposerFilesToDraft('claim-1', [
|
||||
{ name: 'hotel.pdf' },
|
||||
{ name: 'taxi.pdf' }
|
||||
])
|
||||
|
||||
assert.deepEqual(uploadCalls, [
|
||||
{ claimId: 'claim-1', itemId: 'item-empty-1', fileName: 'hotel.pdf' },
|
||||
{ claimId: 'claim-1', itemId: 'item-created-1', fileName: 'taxi.pdf' }
|
||||
])
|
||||
assert.equal(createCount, 1)
|
||||
assert.equal(result.uploadedCount, 2)
|
||||
assert.equal(result.skippedCount, 0)
|
||||
})
|
||||
|
||||
test('review summary renders markdown and save draft relies on backend response only', () => {
|
||||
assert.match(
|
||||
createViewTemplate,
|
||||
|
||||
@@ -422,17 +422,24 @@ test('expense detail table shows the amount total below detail rows', () => {
|
||||
assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/)
|
||||
})
|
||||
|
||||
test('additional note is shown above expense details as travel purpose text', () => {
|
||||
test('related application information is shown above expense details for reimbursement check', () => {
|
||||
assert.ok(
|
||||
detailViewTemplate.indexOf('<h3>附加说明</h3>')
|
||||
detailViewTemplate.indexOf('<h3>关联单据信息</h3>')
|
||||
< detailViewTemplate.indexOf("isApplicationDocument ? '申请详情' : '费用明细'")
|
||||
)
|
||||
assert.match(detailViewTemplate, /<article v-if="!isApplicationDocument" class="detail-card panel">/)
|
||||
assert.match(detailViewTemplate, /用于说明本次出差或办事目的/)
|
||||
assert.match(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
|
||||
assert.match(detailViewTemplate, /v-else class="detail-note readonly"/)
|
||||
assert.match(detailViewTemplate, /v-model="detailNoteEditorView"/)
|
||||
assert.match(detailViewTemplate, /提交后将作为明确说明展示/)
|
||||
assert.match(detailViewTemplate, /展示本次报销关联的前置申请/)
|
||||
assert.match(detailViewTemplate, /relatedApplicationFactItems/)
|
||||
assert.match(detailViewTemplate, /暂未识别到关联申请单/)
|
||||
assert.match(detailViewScript, /buildRelatedApplicationFactItems/)
|
||||
assert.match(requestsComposableScript, /const RELATED_APPLICATION_STEP_LABEL = '关联单据'/)
|
||||
assert.match(requestsComposableScript, /const ARCHIVED_STEP_LABEL = '已归档'/)
|
||||
assert.match(detailViewStyle, /\.related-application-empty/)
|
||||
assert.doesNotMatch(detailViewTemplate, /v-if="canEditDetailNote" class="detail-note-editor"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /v-model="detailNoteEditorView"/)
|
||||
})
|
||||
|
||||
test('detail note model is retained for risk override persistence', () => {
|
||||
assert.match(detailViewScript, /const canEditDetailNote = computed\(\(\) => isDraftRequest\.value\)/)
|
||||
assert.match(detailViewScript, /function normalizeDetailNoteDraftValue\(value\)/)
|
||||
assert.match(detailViewScript, /function stripRiskTagsForDisplay\(value\)/)
|
||||
|
||||
@@ -72,7 +72,8 @@ test('detail submit requires override reasons for high-risk claims', () => {
|
||||
|
||||
test('detail header and fallback progress use reimbursement wording', () => {
|
||||
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
|
||||
assert.match(detailExpenseModelScript, /label:\s*'创建单据'/)
|
||||
assert.match(detailExpenseModelScript, /label:\s*'关联单据'/)
|
||||
assert.match(detailExpenseModelScript, /label:\s*'已归档'/)
|
||||
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
||||
})
|
||||
|
||||
@@ -80,6 +81,6 @@ test('archived detail delete action is gated by admin-only permission', () => {
|
||||
assert.match(detailViewScript, /canDeleteArchivedExpenseClaims/)
|
||||
assert.match(detailViewScript, /isArchivedRequestView/)
|
||||
assert.match(detailViewScript, /if \(isArchivedRequest\.value\) {\s*return canDeleteArchivedExpenseClaims\(currentUser\.value\)/)
|
||||
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canDeleteRequest"/)
|
||||
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
|
||||
})
|
||||
|
||||
130
web/tests/workbench-application-intent.test.mjs
Normal file
130
web/tests/workbench-application-intent.test.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION,
|
||||
ASSISTANT_SCOPE_SESSION_EXPENSE,
|
||||
ASSISTANT_SCOPE_SESSION_KNOWLEDGE,
|
||||
inferAssistantScopeTarget
|
||||
} from '../src/utils/assistantSessionScope.js'
|
||||
import {
|
||||
resolveWorkbenchSessionTypeFromOntology
|
||||
} from '../src/utils/workbenchAssistantIntent.js'
|
||||
|
||||
const appShellRouteView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const appShellComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useAppShell.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const assistantScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('workbench prompt applies travel phrases to application assistant scope', () => {
|
||||
assert.equal(inferAssistantScopeTarget('申请出差'), ASSISTANT_SCOPE_SESSION_APPLICATION)
|
||||
assert.equal(
|
||||
inferAssistantScopeTarget('去北京出差3天,支撑国网仿生产环境部署'),
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
inferAssistantScopeTarget('去国网出差3天,协助仿生产环境部署'),
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
inferAssistantScopeTarget('下周去上海支撑客户系统上线,预计3天'),
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
inferAssistantScopeTarget('安排去深圳客户现场验收项目'),
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
inferAssistantScopeTarget('准备去国网现场做仿生产环境部署,差旅3天'),
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
inferAssistantScopeTarget('我要报销去北京的费用'),
|
||||
ASSISTANT_SCOPE_SESSION_EXPENSE
|
||||
)
|
||||
assert.equal(
|
||||
inferAssistantScopeTarget('我要报销去北京出差的费用'),
|
||||
ASSISTANT_SCOPE_SESSION_EXPENSE
|
||||
)
|
||||
assert.equal(
|
||||
inferAssistantScopeTarget('去北京出差报销标准是多少'),
|
||||
ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||
)
|
||||
assert.notEqual(
|
||||
inferAssistantScopeTarget('昨天去北京出差花了1000元'),
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
)
|
||||
assert.match(appShellComposable, /fetchOntologyParse/)
|
||||
assert.match(appShellComposable, /resolveWorkbenchSessionTypeFromOntology/)
|
||||
assert.match(appShellComposable, /resolveWorkbenchSessionTypeFallback/)
|
||||
assert.match(appShellRouteView, /:initial-session-type="smartEntryContext\.sessionType"/)
|
||||
assert.match(assistantScript, /initialSessionType:\s*\{[\s\S]*type:\s*String/)
|
||||
})
|
||||
|
||||
test('workbench model routing maps ontology result before entering assistant', () => {
|
||||
const travelOntology = {
|
||||
scenario: 'expense',
|
||||
intent: 'draft',
|
||||
entities: [
|
||||
{ type: 'expense_type', normalized_value: 'travel' }
|
||||
]
|
||||
}
|
||||
const reimbursementOntology = {
|
||||
scenario: 'expense',
|
||||
intent: 'draft',
|
||||
entities: [
|
||||
{ type: 'expense_type', normalized_value: 'travel' }
|
||||
]
|
||||
}
|
||||
const applicationOntology = {
|
||||
scenario: 'expense',
|
||||
intent: 'draft',
|
||||
entities: [
|
||||
{ type: 'document_type', normalized_value: 'expense_application' },
|
||||
{ type: 'workflow_stage', normalized_value: 'pre_approval' }
|
||||
]
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
resolveWorkbenchSessionTypeFromOntology(
|
||||
travelOntology,
|
||||
'下周去上海支撑客户系统上线,预计3天',
|
||||
ASSISTANT_SCOPE_SESSION_EXPENSE
|
||||
),
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkbenchSessionTypeFromOntology(
|
||||
reimbursementOntology,
|
||||
'我要报销去北京出差的费用',
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
),
|
||||
ASSISTANT_SCOPE_SESSION_EXPENSE
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkbenchSessionTypeFromOntology(
|
||||
{ scenario: 'expense', intent: 'query', entities: reimbursementOntology.entities },
|
||||
'去北京出差报销标准是多少',
|
||||
ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||
),
|
||||
ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||
)
|
||||
assert.equal(
|
||||
resolveWorkbenchSessionTypeFromOntology(
|
||||
applicationOntology,
|
||||
'国网仿生产环境部署',
|
||||
ASSISTANT_SCOPE_SESSION_EXPENSE
|
||||
),
|
||||
ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
)
|
||||
})
|
||||
99
web/tests/workbench-composer-date.test.mjs
Normal file
99
web/tests/workbench-composer-date.test.mjs
Normal file
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
canApplyWorkbenchDateSelection,
|
||||
getTodayDateValue,
|
||||
mergeWorkbenchDateLabelIntoDraft,
|
||||
stripWorkbenchDateLabelFromDraft
|
||||
} from '../src/utils/workbenchComposerDate.js'
|
||||
|
||||
const workbench = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/business/PersonalWorkbench.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchDateComposable = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/useWorkbenchComposerDate.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchDateStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-composer-date.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const workbenchResponsiveStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/personal-workbench-responsive.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('workbench composer renders date picker beside attachment upload', () => {
|
||||
assert.match(workbench, /aria-label="上传附件"[\s\S]*class="workbench-date-anchor"/)
|
||||
assert.match(workbench, /aria-label="选择日期"/)
|
||||
assert.match(workbench, /class="workbench-date-chip"/)
|
||||
assert.match(workbench, /removeWorkbenchDateTag/)
|
||||
assert.match(workbench, /composer-date-popover/)
|
||||
assert.match(workbench, /setWorkbenchDateMode\('single'\)/)
|
||||
assert.match(workbench, /setWorkbenchDateMode\('range'\)/)
|
||||
assert.match(workbench, /handleWorkbenchDateInputChange\('single'\)/)
|
||||
assert.match(workbench, /handleWorkbenchDateInputChange\('range-start'\)/)
|
||||
assert.match(workbench, /handleWorkbenchDateInputChange\('range-end'\)/)
|
||||
assert.doesNotMatch(workbench, /@click="applyWorkbenchDateSelection"/)
|
||||
assert.doesNotMatch(workbench, /插入标签/)
|
||||
assert.match(workbench, /useWorkbenchComposerDate/)
|
||||
assert.match(workbenchDateComposable, /const workbenchSingleDate = ref\(getTodayDateValue\(\)\)/)
|
||||
assert.match(workbenchDateComposable, /const workbenchDateTagLabel = ref\(''\)/)
|
||||
assert.match(workbenchDateComposable, /const today = getTodayDateValue\(\)[\s\S]*workbenchSingleDate\.value = today/)
|
||||
assert.match(workbenchDateComposable, /function handleWorkbenchDateInputChange/)
|
||||
assert.match(workbenchDateStyles, /\.workbench-date-anchor/)
|
||||
assert.match(workbenchDateStyles, /\.workbench-date-chip/)
|
||||
assert.match(workbenchDateStyles, /\.composer-date-popover/)
|
||||
assert.match(workbenchStyles, /\.assistant-composer\s*\{[\s\S]*position:\s*relative/)
|
||||
assert.match(workbenchDateStyles, /\.composer-date-popover\s*\{[\s\S]*top:\s*calc\(100% \+ 8px\)/)
|
||||
assert.doesNotMatch(workbenchDateStyles, /bottom:\s*calc\(100%/)
|
||||
assert.doesNotMatch(workbench, /composer-related-button/)
|
||||
assert.doesNotMatch(workbenchStyles, /\.composer-related-button/)
|
||||
assert.doesNotMatch(workbenchDateStyles, /\.composer-related-button/)
|
||||
assert.doesNotMatch(workbenchResponsiveStyles, /\.composer-related-button/)
|
||||
})
|
||||
|
||||
test('workbench date helper builds labels and inserts them into draft text', () => {
|
||||
assert.equal(getTodayDateValue(new Date(2026, 4, 9, 12)), '2026-05-09')
|
||||
assert.equal(
|
||||
buildWorkbenchDateLabel({
|
||||
mode: 'single',
|
||||
singleDate: '2026-05-29'
|
||||
}),
|
||||
'2026-05-29'
|
||||
)
|
||||
assert.equal(
|
||||
buildWorkbenchDateLabel({
|
||||
mode: 'range',
|
||||
rangeStartDate: '2026-05-29',
|
||||
rangeEndDate: '2026-05-31'
|
||||
}),
|
||||
'2026-05-29 至 2026-05-31'
|
||||
)
|
||||
assert.equal(
|
||||
mergeWorkbenchDateLabelIntoDraft('申请出差', '2026-05-29'),
|
||||
'2026-05-29,申请出差'
|
||||
)
|
||||
assert.equal(
|
||||
mergeWorkbenchDateLabelIntoDraft('发生时间:2026-05-28,申请出差', '2026-05-29'),
|
||||
'2026-05-29,申请出差'
|
||||
)
|
||||
assert.equal(
|
||||
mergeWorkbenchDateLabelIntoDraft('2026-05-28,申请出差', '2026-05-29'),
|
||||
'2026-05-29,申请出差'
|
||||
)
|
||||
assert.equal(
|
||||
stripWorkbenchDateLabelFromDraft('2026-05-28 至 2026-05-30,申请出差'),
|
||||
'申请出差'
|
||||
)
|
||||
assert.equal(canApplyWorkbenchDateSelection({ mode: 'range', rangeStartDate: '2026-06-01', rangeEndDate: '2026-05-31' }), false)
|
||||
})
|
||||
Reference in New Issue
Block a user