feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造
- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制 - 引入费用审批动态路由、平台风险分级、预审与风险阶段管理 - 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板 - 新增 Hermes 风险线索收集器、Agent 链路追踪中心 - 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估 - 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
@@ -40,7 +40,9 @@ test('direct approvers can return claims without receiving delete permissions',
|
||||
false
|
||||
)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: [], grade: 'P8' }), false)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'] }), true)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'] }), false)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'], grade: 'P7' }), false)
|
||||
assert.equal(canApproveBudgetExpenseApplications({ roleCodes: ['executive'], grade: 'P8' }), true)
|
||||
assert.equal(canManageExpenseClaims(managerUser), false)
|
||||
assert.equal(canManageExpenseClaims(approverUser), false)
|
||||
})
|
||||
@@ -101,10 +103,11 @@ test('finance approval inbox only processes finance-stage requests', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('budget approval inbox only processes budget-stage requests for budget monitor or senior finance roles', () => {
|
||||
test('budget approval inbox only processes budget-stage requests for department P8 budget approvers', () => {
|
||||
const budgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '赵预算', departmentName: '交付部' }
|
||||
const otherDepartmentBudgetUser = { roleCodes: ['budget_monitor'], grade: 'P8', name: '王预算', departmentName: '财务部' }
|
||||
const seniorFinanceUser = { roleCodes: ['executive'], grade: 'P7', name: '高级财务' }
|
||||
const p8ExecutiveBudgetUser = { roleCodes: ['executive'], grade: 'P8', name: 'P8 Executive', departmentName: '交付部' }
|
||||
const p8WithoutBudgetRole = { roleCodes: ['manager'], grade: 'P8', name: '高职级经理' }
|
||||
|
||||
assert.equal(
|
||||
@@ -113,6 +116,10 @@ test('budget approval inbox only processes budget-stage requests for budget moni
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, seniorFinanceUser),
|
||||
false
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '预算管理者审批', person: '张三', departmentName: '交付部' }, p8ExecutiveBudgetUser),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
@@ -170,3 +177,35 @@ test('direct-manager approval helpers only match claims pushed to the current us
|
||||
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '李经理' }, managerUser), true)
|
||||
assert.equal(isCurrentDirectManagerForRequest({ person: '张三', managerName: '王总' }, managerUser), false)
|
||||
})
|
||||
|
||||
test('applicant helper matches generated draft owner by employee identifiers', () => {
|
||||
const currentUser = {
|
||||
username: 'caoxiaozhu@xf.com',
|
||||
email: 'caoxiaozhu@xf.com',
|
||||
employeeNo: 'E90919',
|
||||
name: '曹笑竹'
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
isCurrentRequestApplicant(
|
||||
{
|
||||
employeeNo: 'E90919',
|
||||
employeeName: '曹笑竹',
|
||||
person: '曹笑竹'
|
||||
},
|
||||
currentUser
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
isCurrentRequestApplicant(
|
||||
{
|
||||
employeeNo: 'E10001',
|
||||
employeeName: '张三',
|
||||
person: '张三'
|
||||
},
|
||||
currentUser
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
@@ -52,6 +52,10 @@ async function testInjectsAuthenticatedUserHeaders() {
|
||||
JSON.stringify({
|
||||
username: 'admin',
|
||||
name: 'Admin User',
|
||||
employeePosition: 'System Manager',
|
||||
employeeGrade: 'M5',
|
||||
employeeNo: 'E-001',
|
||||
managerName: 'Approver User',
|
||||
roleCodes: ['manager'],
|
||||
isAdmin: true
|
||||
})
|
||||
@@ -82,6 +86,10 @@ async function testInjectsAuthenticatedUserHeaders() {
|
||||
|
||||
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
|
||||
assert.equal(capturedOptions.headers['x-auth-name'], 'Admin User')
|
||||
assert.equal(capturedOptions.headers['x-auth-position'], 'System Manager')
|
||||
assert.equal(capturedOptions.headers['x-auth-grade'], 'M5')
|
||||
assert.equal(capturedOptions.headers['x-auth-employee-no'], 'E-001')
|
||||
assert.equal(capturedOptions.headers['x-auth-manager-name'], 'Approver User')
|
||||
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
||||
}
|
||||
|
||||
@@ -125,6 +125,82 @@ test('application detail topbar does not ask for receipt attachments', () => {
|
||||
assert.deepEqual(alerts, ['SLA 催单次数 0'])
|
||||
})
|
||||
|
||||
test('detail topbar surfaces stored medium and high risk flags first', () => {
|
||||
const highAlerts = buildDetailAlerts({
|
||||
node: 'AI预审',
|
||||
approvalKey: 'draft',
|
||||
riskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
hit_source: 'rule_center',
|
||||
severity: 'high',
|
||||
message: '票据日期超出申报差旅行程。'
|
||||
},
|
||||
{
|
||||
source: 'submission_review',
|
||||
hit_source: 'rule_center',
|
||||
severity: 'medium',
|
||||
message: '票据城市需要人工核对。'
|
||||
}
|
||||
],
|
||||
expenseItems: []
|
||||
})
|
||||
const mediumAlerts = buildDetailAlerts({
|
||||
node: 'AI预审',
|
||||
approvalKey: 'draft',
|
||||
riskFlags: [
|
||||
{
|
||||
source: 'submission_review',
|
||||
hit_source: 'rule_center',
|
||||
severity: 'medium',
|
||||
message: '票据城市需要人工核对。'
|
||||
}
|
||||
],
|
||||
expenseItems: []
|
||||
})
|
||||
|
||||
assert.equal(highAlerts[0].label, '高风险 1 项')
|
||||
assert.equal(highAlerts[0].tone, 'danger')
|
||||
assert.equal(mediumAlerts[0].label, '中风险 1 项')
|
||||
assert.equal(mediumAlerts[0].tone, 'warning')
|
||||
})
|
||||
|
||||
test('detail topbar does not treat handoff or SLA events as risk flags', () => {
|
||||
const alerts = buildDetailAlerts({
|
||||
node: '待提交',
|
||||
approvalKey: 'draft',
|
||||
typeCode: 'travel',
|
||||
typeLabel: '差旅费',
|
||||
reason: '上海项目出差',
|
||||
location: '上海',
|
||||
city: '上海',
|
||||
occurredDisplay: '2026-05-13',
|
||||
amountValue: 300,
|
||||
riskFlags: [
|
||||
{
|
||||
source: 'application_handoff',
|
||||
event_type: 'expense_application_to_reimbursement_draft',
|
||||
severity: 'info',
|
||||
message: '费用申请已生成报销草稿。'
|
||||
},
|
||||
{ source: 'sla_reminder', message: '下属已催单' }
|
||||
],
|
||||
expenseItems: [
|
||||
{
|
||||
id: 'train',
|
||||
itemType: 'train_ticket',
|
||||
itemReason: '武汉-上海',
|
||||
itemLocation: '上海',
|
||||
itemDate: '2026-05-13',
|
||||
itemAmount: 300,
|
||||
invoiceId: 'ticket.pdf'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(alerts.map((item) => item.label), ['SLA 催单次数 1'])
|
||||
})
|
||||
|
||||
test('detail topbar shows SLA reminder count from direct fields and reminder events', () => {
|
||||
const directAlerts = buildDetailAlerts({
|
||||
node: '直属领导审批',
|
||||
|
||||
91
web/tests/digital-employee-dashboard.test.mjs
Normal file
91
web/tests/digital-employee-dashboard.test.mjs
Normal file
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { normalizeDigitalEmployeeDashboardPayload } from '../src/services/analytics.js'
|
||||
|
||||
const topBar = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
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 dashboardComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/dashboard/DigitalEmployeeDashboard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const dailyChartComponent = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/charts/DigitalEmployeeDailyWorkChart.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const dashboardModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/overviewDigitalEmployeeDashboardModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('digital employee dashboard normalizes backend payload fields', () => {
|
||||
const dashboard = normalizeDigitalEmployeeDashboardPayload({
|
||||
window_days: 7,
|
||||
generated_at: '2026-06-01T08:00:00Z',
|
||||
has_real_data: true,
|
||||
totals: {
|
||||
totalRuns: 3,
|
||||
successRuns: 2,
|
||||
failedRuns: 1,
|
||||
businessOutputs: 8
|
||||
},
|
||||
daily_work: [{ date: '06-01', total: 3, businessOutputs: 8 }],
|
||||
task_distribution: [{ name: '知识制度整理', count: 2 }],
|
||||
category_distribution: [{ name: '整理', count: 2 }],
|
||||
recent_runs: [{ runId: 'run-001', taskLabel: '知识制度整理' }]
|
||||
})
|
||||
|
||||
assert.equal(dashboard.windowDays, 7)
|
||||
assert.equal(dashboard.generatedAt, '2026-06-01T08:00:00Z')
|
||||
assert.equal(dashboard.hasRealData, true)
|
||||
assert.equal(dashboard.totals.totalRuns, 3)
|
||||
assert.equal(dashboard.dailyWork[0].businessOutputs, 8)
|
||||
assert.equal(dashboard.taskDistribution[0].name, '知识制度整理')
|
||||
assert.equal(dashboard.categoryDistribution[0].name, '整理')
|
||||
assert.equal(dashboard.recentRuns[0].runId, 'run-001')
|
||||
})
|
||||
|
||||
test('digital employee dashboard is wired into overview dashboard switch', () => {
|
||||
assert.match(topBar, /label: '数字员工看板', value: 'digitalEmployee'/)
|
||||
assert.match(overviewView, /<DigitalEmployeeDashboard/)
|
||||
assert.match(overviewView, /activeDashboard === 'digitalEmployee'/)
|
||||
assert.match(overviewView, /digitalEmployeeKpiMetrics/)
|
||||
assert.match(overviewViewModel, /fetchDigitalEmployeeDashboard/)
|
||||
assert.match(overviewViewModel, /const digitalEmployeeKpiMetrics = computed/)
|
||||
assert.match(dashboardModel, /label: '工作总数'/)
|
||||
assert.match(dashboardModel, /label: '成功数量'/)
|
||||
assert.match(dashboardModel, /label: '失败数量'/)
|
||||
assert.match(dashboardModel, /categoryDistribution/)
|
||||
assert.match(analyticsService, /\/analytics\/digital-employee-dashboard/)
|
||||
})
|
||||
|
||||
test('digital employee dashboard renders enterprise dashboard panels with chart components', () => {
|
||||
assert.match(dashboardComponent, /每日工作趋势/)
|
||||
assert.match(dashboardComponent, /每日工作摘要/)
|
||||
assert.match(dashboardComponent, /技能类型分布/)
|
||||
assert.match(dashboardComponent, /工作模块排行/)
|
||||
assert.match(dashboardComponent, /最近工作记录/)
|
||||
assert.match(dashboardComponent, /DigitalEmployeeDailyWorkChart/)
|
||||
assert.match(dashboardComponent, /DonutChart/)
|
||||
assert.match(dashboardComponent, /BarChart/)
|
||||
assert.match(dailyChartComponent, /echarts\/charts/)
|
||||
assert.match(dailyChartComponent, /name: '工作次数'/)
|
||||
assert.match(dailyChartComponent, /name: '业务产出'/)
|
||||
assert.doesNotMatch(dashboardComponent, /hermes/i)
|
||||
})
|
||||
@@ -69,10 +69,43 @@ test('digital employee profile run resolves from tool request when route is spar
|
||||
assert.equal(extractWorkRecordToolSummary(run).snapshot_count, 16)
|
||||
})
|
||||
|
||||
test('digital employee risk clue run resolves review packet metadata', () => {
|
||||
const run = {
|
||||
route_json: {
|
||||
task_code: 'task.hermes.risk_rule_discovery',
|
||||
task_name: '风险线索归集'
|
||||
},
|
||||
tool_calls: [
|
||||
{
|
||||
tool_name: 'digital_employee.risk_clue.collect',
|
||||
request_json: { task_type: 'risk_clue_collect' },
|
||||
response_json: {
|
||||
fact_count: 5,
|
||||
rule_hit_count: 3,
|
||||
risk_clue_count: 2,
|
||||
risk_clues: [{ clue_id: 'risk_clue:1', title: '待复核线索' }],
|
||||
feedback_summary: {
|
||||
total: 1,
|
||||
recent: [{ feedback_id: 'fb-1', feedback_type: 'comment', observation_key: 'risk:c1' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert.equal(resolveWorkRecordTaskType(run), 'risk_clue_collect')
|
||||
assert.equal(resolveWorkRecordProductKind(run), 'risk_clue')
|
||||
assert.equal(resolveWorkRecordModuleLabel(run), '风险线索归集')
|
||||
assert.equal(extractWorkRecordToolSummary(run).risk_clue_count, 2)
|
||||
})
|
||||
|
||||
test('digital employee work record product supports scoped observation expansion', () => {
|
||||
assert.match(runProductsComponent, /activeObservationKey/)
|
||||
assert.match(runProductsComponent, /toggleObservation/)
|
||||
assert.match(runProductsComponent, /异常关系/)
|
||||
assert.match(runProductsComponent, /待复核线索/)
|
||||
assert.match(runProductsComponent, /反馈样本/)
|
||||
assert.match(runProductsComponent, /formatFeedbackStatus/)
|
||||
assert.doesNotMatch(runProductsComponent, /KnowledgeIngestGraphView/)
|
||||
})
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
formatDocumentListTime,
|
||||
formatDocumentDurationSince,
|
||||
resolveDocumentStayTimeDisplay
|
||||
} from '../src/utils/documentCenterTime.js'
|
||||
|
||||
test('document center list time keeps the full year in created time', () => {
|
||||
assert.equal(formatDocumentListTime('2026-05-20T09:30:00'), '2026-05-20 09:30')
|
||||
assert.equal(formatDocumentListTime('2026-05-20 创建成功'), '2026-05-20 创建成功')
|
||||
})
|
||||
|
||||
test('document center stay time uses current workflow step stay label first', () => {
|
||||
const label = resolveDocumentStayTimeDisplay({
|
||||
progressSteps: [
|
||||
|
||||
@@ -17,12 +17,18 @@ import {
|
||||
normalizeApplicationPreview,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../src/utils/expenseApplicationPreview.js'
|
||||
import {
|
||||
buildMockApplicationTransportEstimate,
|
||||
resolveMockApplicationTransportWaitMs,
|
||||
buildSystemApplicationEstimate
|
||||
} from '../src/utils/expenseApplicationEstimate.js'
|
||||
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||
import {
|
||||
createMessage as createConversationMessage,
|
||||
hasMeaningfulSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationModel.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
|
||||
|
||||
const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
@@ -110,14 +116,24 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
false
|
||||
)
|
||||
|
||||
const preview = buildLocalApplicationPreview(prompt, { name: '李文静', departmentName: '财务部', grade: 'P5' })
|
||||
const preview = buildLocalApplicationPreview(prompt, {
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.applicationType, '差旅费用申请')
|
||||
assert.equal(preview.fields.time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.days, '3天')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.equal(preview.fields.amount, '2358元')
|
||||
assert.equal(preview.fields.applicant, '李文静')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.equal(preview.fields.department, '财务部')
|
||||
assert.equal(preview.fields.position, '财务分析师')
|
||||
assert.equal(preview.fields.managerName, '王强')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/)
|
||||
assert.match(buildApplicationPreviewFooterMessage(preview), /#application-submit/)
|
||||
@@ -127,6 +143,9 @@ test('application intent uses local preview instead of immediate orchestrator ca
|
||||
test('application preview renders ordered editable rows and submit text uses edited values', () => {
|
||||
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去新疆,伊犁出差,服务美团业务部署,火车,预计费用1800元', {
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
grade: 'P5'
|
||||
})
|
||||
assert.equal(preview.fields.location, '新疆,伊犁')
|
||||
@@ -143,14 +162,62 @@ test('application preview renders ordered editable rows and submit text uses edi
|
||||
const rows = buildApplicationPreviewRows(editedPreview)
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.label),
|
||||
['申请类型', '职级', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '用户预估费用']
|
||||
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
|
||||
)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'applicant')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'grade')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'department')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'position')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'managerName')?.editable, false)
|
||||
assert.equal(rows.find((row) => row.key === 'lodgingDailyCap')?.editable, false)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /姓名:李文静/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /部门:财务部/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /岗位:财务分析师/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /直属领导:王强/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /事由:客户现场项目支持/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /用户预估费用:1900元/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用:1900元/)
|
||||
})
|
||||
|
||||
test('application estimate builds deterministic mock transport amount and total', () => {
|
||||
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
|
||||
const datedTrainEstimate = buildMockApplicationTransportEstimate({
|
||||
transportMode: '高铁',
|
||||
location: '上海',
|
||||
time: '2026-05-25 至 2026-05-28'
|
||||
})
|
||||
const flightEstimate = buildMockApplicationTransportEstimate({ transportMode: '机票', location: '新疆,伊犁' })
|
||||
const shipEstimate = buildMockApplicationTransportEstimate({ transportMode: '船票', location: '厦门' })
|
||||
const totalEstimate = buildSystemApplicationEstimate({
|
||||
transportMode: '火车',
|
||||
location: '上海',
|
||||
lodgingAmount: 1800,
|
||||
allowanceAmount: 360
|
||||
})
|
||||
const datedTotalEstimate = buildSystemApplicationEstimate({
|
||||
transportMode: '火车',
|
||||
location: '上海',
|
||||
time: '2026-05-25 至 2026-05-28',
|
||||
lodgingAmount: 1800,
|
||||
allowanceAmount: 360
|
||||
})
|
||||
|
||||
assert.equal(trainEstimate.amountDisplay, '1,040')
|
||||
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
||||
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
|
||||
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
|
||||
assert.match(datedTrainEstimate.basisText, /查询耗时 \d+ms/)
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
||||
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
|
||||
assert.equal(flightEstimate.amountDisplay, '3,600')
|
||||
assert.equal(shipEstimate.amountDisplay, '1,040')
|
||||
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
|
||||
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
|
||||
assert.equal(datedTotalEstimate.transportAmountDisplay, '1,100')
|
||||
assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260')
|
||||
})
|
||||
|
||||
test('application preview cleans empty time labels and keeps only business reason', () => {
|
||||
@@ -258,6 +325,8 @@ test('application quick start renders a template without model review', () => {
|
||||
const preview = buildApplicationTemplatePreview({
|
||||
name: '李文静',
|
||||
departmentName: '财务部',
|
||||
position: '财务分析师',
|
||||
managerName: '王强',
|
||||
grade: 'P5'
|
||||
})
|
||||
const message = buildLocalApplicationPreviewMessage(preview)
|
||||
@@ -266,6 +335,8 @@ test('application quick start renders a template without model review', () => {
|
||||
assert.equal(preview.fields.applicationType, '费用申请')
|
||||
assert.equal(preview.fields.applicant, '李文静')
|
||||
assert.equal(preview.fields.department, '财务部')
|
||||
assert.equal(preview.fields.position, '财务分析师')
|
||||
assert.equal(preview.fields.managerName, '王强')
|
||||
assert.equal(preview.fields.grade, 'P5')
|
||||
assert.equal(buildApplicationPreviewRows(preview).find((row) => row.key === 'grade')?.editable, false)
|
||||
assert.match(message, /不调用大模型/)
|
||||
@@ -389,7 +460,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
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 \{[\s\S]*gap: 1px;[\s\S]*border: 1px solid #d7e4f2;[\s\S]*background: #d7e4f2;/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item \{[\s\S]*border: 0;[\s\S]*background: #ffffff;/)
|
||||
assert.doesNotMatch(applicationMessageStyles, /\.application-draft-brief-item:nth-child\(even\)/)
|
||||
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
|
||||
|
||||
assert.match(flowScript, /application-submit-success/)
|
||||
@@ -490,7 +563,64 @@ test('application preview merges rule center travel estimate into highlighted ro
|
||||
|
||||
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
||||
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /实报实销/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /2,160元/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /参考票价/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /2026-05-25/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
|
||||
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
|
||||
assert.equal(estimatedPreview.fields.transportEstimateDate, '2026-05-25')
|
||||
assert.match(estimatedPreview.fields.transportQueryLatencyMs, /^\d+ms$/)
|
||||
assert.equal(estimatedPreview.fields.amount, '3,260元')
|
||||
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
|
||||
})
|
||||
|
||||
test('application preview editor refreshes transport estimate after mode change', async () => {
|
||||
const preview = applyApplicationPolicyEstimateResult(
|
||||
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天,服务项目部署', {
|
||||
name: '李文静',
|
||||
grade: 'P5'
|
||||
}),
|
||||
{
|
||||
days: 3,
|
||||
location: '上海',
|
||||
matched_city: '上海',
|
||||
grade: 'P5',
|
||||
hotel_rate: 600,
|
||||
hotel_amount: 1800,
|
||||
total_allowance_rate: 120,
|
||||
allowance_amount: 360,
|
||||
total_amount: 2160
|
||||
},
|
||||
{ grade: 'P5' }
|
||||
)
|
||||
const message = {
|
||||
id: 'application-preview-editor-message',
|
||||
applicationPreview: preview,
|
||||
text: ''
|
||||
}
|
||||
let persistCount = 0
|
||||
const toastMessages = []
|
||||
const editor = useApplicationPreviewEditor({
|
||||
persistSessionState: () => {
|
||||
persistCount += 1
|
||||
},
|
||||
toast: (messageText) => {
|
||||
toastMessages.push(messageText)
|
||||
}
|
||||
})
|
||||
|
||||
editor.openApplicationPreviewEditor(message, 'transportMode', '待补充')
|
||||
editor.applicationPreviewEditor.value.draftValue = '飞机'
|
||||
const committed = await editor.commitApplicationPreviewEditor(message)
|
||||
|
||||
assert.equal(committed, true)
|
||||
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
||||
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
|
||||
assert.equal(message.applicationPreview.fields.amount, '4,490元')
|
||||
assert.match(message.applicationPreview.fields.transportPolicy, /已查询 2026-05-25 飞机参考票价/)
|
||||
assert.match(message.applicationPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
||||
assert.ok(persistCount >= 2)
|
||||
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
|
||||
})
|
||||
|
||||
@@ -11,26 +11,33 @@ function readProjectFile(path) {
|
||||
function testReceiptFolderViewSurface() {
|
||||
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
|
||||
|
||||
assert.match(view, /未关联票据/)
|
||||
assert.match(view, /已关联票据/)
|
||||
assert.match(view, /label: '全部'/)
|
||||
assert.match(view, /一键关联票据/)
|
||||
assert.match(view, /票据关键字段/)
|
||||
assert.match(view, /原始文件/)
|
||||
assert.match(view, /activeStatus = ref\('all'\)/)
|
||||
assert.match(view, /value: 'all'/)
|
||||
assert.match(view, /openAssociateDialog/)
|
||||
assert.match(view, /receipt-detail-toolbar/)
|
||||
assert.match(view, /receipt-dashboard/)
|
||||
assert.match(view, /receipt-dashboard-preview/)
|
||||
assert.match(view, /receipt-dashboard-side/)
|
||||
assert.match(view, /receipt-dashboard-bottom/)
|
||||
assert.match(view, /receipt-ocr-panel/)
|
||||
assert.match(view, /receipt-status-panel/)
|
||||
assert.match(view, /keyReceiptFields/)
|
||||
assert.match(view, /editableOtherFields/)
|
||||
assert.match(view, /ocrPreviewFields/)
|
||||
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, /previewTransform/)
|
||||
assert.match(view, /openAssociateDialogForCurrentReceipt/)
|
||||
assert.match(view, /createReceiptDetailDashboardModel/)
|
||||
assert.match(view, /ElCollapse/)
|
||||
assert.doesNotMatch(view, /新增字段/)
|
||||
assert.doesNotMatch(view, /addField/)
|
||||
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, /back-label=/)
|
||||
assert.doesNotMatch(view, /back-btn/)
|
||||
assert.match(view, /deleteCurrentReceipt/)
|
||||
assert.match(view, /ElCheckboxGroup/)
|
||||
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
|
||||
assert.match(view, /buildReceiptFile\(item\)/)
|
||||
@@ -90,8 +97,7 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
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, /document_date/)
|
||||
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'\)/)
|
||||
@@ -100,12 +106,14 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
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, /receipt-dashboard-preview/)
|
||||
assert.match(receiptView, /receipt-dashboard-bottom/)
|
||||
assert.match(receiptView, /createReceiptDetailFieldModel/)
|
||||
assert.match(receiptView, /createReceiptDetailDashboardModel/)
|
||||
assert.match(receiptView, /buildDetailPayload\(\)/)
|
||||
assert.match(receiptView, /receiptDetailSubtitle/)
|
||||
assert.match(receiptView, /receiptDetailTopBarPayload/)
|
||||
assert.match(receiptView, /eyebrow: '票据详情'/)
|
||||
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"/)
|
||||
@@ -115,6 +123,11 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
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-detail-toolbar/)
|
||||
assert.match(receiptStyles, /\.receipt-dashboard/)
|
||||
assert.match(receiptStyles, /\.receipt-dashboard-bottom/)
|
||||
assert.match(receiptStyles, /\.receipt-preview-tools/)
|
||||
assert.match(receiptStyles, /\.receipt-log-list/)
|
||||
assert.match(receiptStyles, /\.receipt-key-grid/)
|
||||
assert.match(receiptStyles, /\.receipt-other-collapse/)
|
||||
assert.match(receiptStyles, /\.receipt-other-scroll/)
|
||||
@@ -124,11 +137,16 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
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, /id: 'invoice_number'/)
|
||||
assert.match(fieldModel, /id: 'invoice_date'/)
|
||||
assert.match(fieldModel, /id: 'fare'/)
|
||||
assert.match(fieldModel, /id: 'passenger_name'/)
|
||||
assert.match(fieldModel, /syncEditableFieldsToTopLevel/)
|
||||
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
|
||||
assert.match(dashboardModel, /createReceiptDetailDashboardModel/)
|
||||
assert.match(dashboardModel, /basicInfoItems/)
|
||||
assert.match(dashboardModel, /operationLogs/)
|
||||
assert.match(dashboardModel, /archiveInfoItems/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
|
||||
@@ -14,8 +14,62 @@ const PAID = '\u5df2\u4ed8\u6b3e'
|
||||
const ARCHIVED = '\u5df2\u5f52\u6863'
|
||||
const WAIT_LEADER_LI_APPROVAL = '\u7b49\u5f85 Leader Li \u6279\u590d'
|
||||
const WAIT_BUDGET_ZHAO_APPROVAL = '\u7b49\u5f85 \u8d75\u9884\u7b97 \u6279\u590d'
|
||||
const WAIT_BUDGET_P8_EXECUTIVE_APPROVAL = '\u7b49\u5f85 P8 Executive \u6279\u590d'
|
||||
const LEADER_RETURNED_STATUS = '\u9886\u5bfc\u5df2\u9000\u56de\uff0c\u5f85\u91cd\u65b0\u63d0\u4ea4'
|
||||
|
||||
test('claim mapper exposes employee identifier for reviewer risk profile lookup', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-profile-1',
|
||||
claim_no: 'EXP-PROFILE-1',
|
||||
employee_id: 'emp-profile-1',
|
||||
employee_name: 'Alice',
|
||||
department_name: 'Finance',
|
||||
expense_type: 'travel',
|
||||
reason: 'Trip',
|
||||
location: 'Shanghai',
|
||||
amount: 1200,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T02:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
risk_flags_json: [],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.employeeId, 'emp-profile-1')
|
||||
assert.equal(request.employee_id, 'emp-profile-1')
|
||||
assert.equal(request.profileEmployeeId, 'emp-profile-1')
|
||||
})
|
||||
|
||||
test('claim mapper falls back to employee name for legacy profile lookup', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-profile-legacy',
|
||||
claim_no: 'EXP-PROFILE-LEGACY',
|
||||
employee_name: 'Legacy Alice',
|
||||
department_name: 'Finance',
|
||||
expense_type: 'travel',
|
||||
reason: 'Trip',
|
||||
location: 'Shanghai',
|
||||
amount: 1200,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T02:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
risk_flags_json: [],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.employeeId, '')
|
||||
assert.equal(request.employee_id, '')
|
||||
assert.equal(request.profileEmployeeId, 'Legacy Alice')
|
||||
})
|
||||
|
||||
test('application claims are mapped as application documents', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-1',
|
||||
@@ -46,11 +100,12 @@ test('application claims are mapped as application documents', () => {
|
||||
assert.equal(request.expenseTableSummary, '预计金额已随申请提交')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, BUDGET_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
[CREATE_APPLICATION, WAIT_LEADER_LI_APPROVAL, APPROVAL_COMPLETED]
|
||||
)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === 'AI预审'), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === '财务审批'), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === DIRECT_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.some((step) => step.label === BUDGET_MANAGER_APPROVAL), false)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.rawLabel, DIRECT_MANAGER_APPROVAL)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
|
||||
})
|
||||
@@ -96,6 +151,48 @@ test('application claims wait for department P8 budget monitor after leader appr
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, 'Leader Li通过')
|
||||
})
|
||||
|
||||
test('application budget wait label uses claim-level budget approver snapshot', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-budget-snapshot',
|
||||
claim_no: 'AP-20260525103145-BUDGET-SNAPSHOT',
|
||||
employee_name: 'Applicant Zhang',
|
||||
department_name: 'Engineering',
|
||||
manager_name: 'Leader Li',
|
||||
budget_approver_name: 'P8 Executive',
|
||||
budget_approver_grade: 'P8',
|
||||
budget_approver_role_code: 'executive',
|
||||
expense_type: 'travel_application',
|
||||
reason: 'Production deployment support',
|
||||
location: 'Beijing',
|
||||
amount: 12000,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T03:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: BUDGET_MANAGER_APPROVAL,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: 'Leader Li',
|
||||
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
next_approval_stage: BUDGET_MANAGER_APPROVAL,
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.budgetApproverName, 'P8 Executive')
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, WAIT_BUDGET_P8_EXECUTIVE_APPROVAL, APPROVAL_COMPLETED]
|
||||
)
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_BUDGET_P8_EXECUTIVE_APPROVAL)?.current, true)
|
||||
})
|
||||
|
||||
test('returned application claims include leader return node and supplement status', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-returned',
|
||||
@@ -235,6 +332,57 @@ test('application claims hide budget step when leader approval also covers budge
|
||||
assert.equal(request.progressSteps.find((step) => step.label === DIRECT_MANAGER_APPROVAL)?.time, '李预算经理通过')
|
||||
})
|
||||
|
||||
test('approved application claims hide budget step when dynamic route skipped budget review', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-skipped-budget',
|
||||
claim_no: 'APP-20260525-SKIPPED',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: 'Leader Li',
|
||||
expense_type: 'travel_application',
|
||||
reason: 'Project onsite support',
|
||||
location: 'Shanghai',
|
||||
amount: 500,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-05-25T00:00:00.000Z',
|
||||
submitted_at: '2026-05-25T02:00:00.000Z',
|
||||
created_at: '2026-05-25T01:30:00.000Z',
|
||||
updated_at: '2026-05-25T03:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: APPROVAL_COMPLETED,
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'approval_routing',
|
||||
event_type: 'expense_application_route_decision',
|
||||
requires_budget_review: false,
|
||||
route: 'approval_done',
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
},
|
||||
{
|
||||
source: 'manual_approval',
|
||||
event_type: 'expense_application_approval',
|
||||
operator: 'Leader Li',
|
||||
previous_approval_stage: DIRECT_MANAGER_APPROVAL,
|
||||
next_approval_stage: APPROVAL_COMPLETED,
|
||||
route_decision: {
|
||||
requires_budget_review: false,
|
||||
route: 'approval_done'
|
||||
},
|
||||
created_at: '2026-05-25T03:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.deepEqual(
|
||||
request.progressSteps.map((step) => step.label),
|
||||
[CREATE_APPLICATION, DIRECT_MANAGER_APPROVAL, APPROVAL_COMPLETED]
|
||||
)
|
||||
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, 'Leader Li通过')
|
||||
})
|
||||
|
||||
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()
|
||||
@@ -567,6 +715,48 @@ test('paid reimbursement marks payment progress step as complete', () => {
|
||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||
})
|
||||
|
||||
test('reimbursement detail resolves linked application from guided entry context', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-linked-context',
|
||||
claim_no: 'EXP-20260520-009',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
expense_type: 'travel',
|
||||
reason: '支撑国网仿生产环境部署',
|
||||
location: '北京',
|
||||
amount: 654,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-20T01:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T02:00:00.000Z',
|
||||
status: 'draft',
|
||||
approval_stage: '待提交',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'application_link',
|
||||
event_type: 'expense_reimbursement_application_linked',
|
||||
review_form_values: {
|
||||
application_claim_id: 'application-guided-1',
|
||||
application_claim_no: 'AP-202605-001',
|
||||
application_reason: '支撑国网仿生产环境部署',
|
||||
application_location: '北京',
|
||||
application_amount: '3000',
|
||||
application_amount_label: '¥3,000'
|
||||
},
|
||||
expense_scene_selection: {
|
||||
application_claim_no: 'AP-202605-001'
|
||||
}
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
assert.equal(request.relatedApplication.claimNo, 'AP-202605-001')
|
||||
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
|
||||
assert.equal(request.relatedApplication.location, '北京')
|
||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||
})
|
||||
|
||||
test('current direct manager step shows how long the claim has stayed there', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime()
|
||||
|
||||
@@ -21,6 +21,8 @@ const overviewTemplate = readFileSync(
|
||||
test('risk dashboard normalizes amount, distributions, and ranking fields', () => {
|
||||
const dashboard = normalizeRiskObservationDashboard({
|
||||
total_observations: 5,
|
||||
risk_clue_count: 2,
|
||||
feedback_sample_count: 3,
|
||||
total_amount: 12800,
|
||||
department_distribution: { 风控部: 3 },
|
||||
expense_type_distribution: { travel: 2 },
|
||||
@@ -35,6 +37,8 @@ test('risk dashboard normalizes amount, distributions, and ranking fields', () =
|
||||
})
|
||||
|
||||
assert.equal(dashboard.totalAmount, 12800)
|
||||
assert.equal(dashboard.riskClueCount, 2)
|
||||
assert.equal(dashboard.feedbackSampleCount, 3)
|
||||
assert.equal(dashboard.departmentDistribution['风控部'], 3)
|
||||
assert.equal(dashboard.expenseTypeDistribution.travel, 2)
|
||||
assert.equal(dashboard.riskTypeDistribution.duplicate_invoice, 2)
|
||||
@@ -51,6 +55,9 @@ test('risk dashboard renders overview amount and multi-dimension panels', () =>
|
||||
assert.match(overviewViewModel, /label: '误报数量'/)
|
||||
assert.match(dashboardComponent, /业务维度分布/)
|
||||
assert.match(dashboardComponent, /异常排行/)
|
||||
assert.match(dashboardComponent, /待复核线索/)
|
||||
assert.match(dashboardComponent, /反馈样本/)
|
||||
assert.doesNotMatch(dashboardComponent, /候选规则/)
|
||||
assert.match(dashboardComponent, /departmentDistribution/)
|
||||
assert.match(dashboardComponent, /expenseTypeDistribution/)
|
||||
assert.match(dashboardComponent, /supplierDistribution/)
|
||||
|
||||
67
web/tests/risk-rule-detail-experience.test.mjs
Normal file
67
web/tests/risk-rule-detail-experience.test.mjs
Normal file
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { buildAuditDetailTopBar } from '../src/views/scripts/auditViewDetailTopBar.js'
|
||||
|
||||
function readSource(path) {
|
||||
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
|
||||
}
|
||||
|
||||
const flowDiagram = readSource('../src/components/shared/RiskRuleFlowDiagram.vue')
|
||||
const testDialog = readSource('../src/components/shared/RiskRuleTestDialog.vue')
|
||||
const testDialogDisplay = readSource('../src/components/shared/riskRuleTestDialogDisplay.js')
|
||||
const auditView = readSource('../src/views/AuditView.vue')
|
||||
const auditRuleDialogs = readSource('../src/components/audit/AuditRuleDialogs.vue')
|
||||
|
||||
test('risk rule detail topbar exposes score, level, status, online and enabled kpis', () => {
|
||||
const payload = buildAuditDetailTopBar({
|
||||
usesJsonRiskRule: true,
|
||||
skill: {
|
||||
name: '差旅票据城市一致性校验',
|
||||
riskRuleSubtitle: '校验票据城市和申报目的地是否一致。',
|
||||
riskRuleScore: 72,
|
||||
riskRuleScoreLabel: '高风险',
|
||||
riskRuleScoreLevel: 'high',
|
||||
riskRuleSeverityLabel: '高风险',
|
||||
status: '已上线',
|
||||
statusTone: 'success',
|
||||
displayVersion: 'v0.1.0',
|
||||
isOnlineLabel: '已上线',
|
||||
isOnlineTone: 'success',
|
||||
isOnlineValue: true,
|
||||
publishedAt: '2026-05-30 10:00',
|
||||
isEnabledLabel: '是',
|
||||
isEnabledTone: 'success',
|
||||
isEnabledValue: true
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(payload.view.title, '差旅票据城市一致性校验')
|
||||
assert.deepEqual(
|
||||
payload.kpis.map((item) => item.label),
|
||||
['风险分', '风险等级', '规则状态', '上线状态', '启用状态']
|
||||
)
|
||||
assert.equal(payload.kpis[0].value, '72')
|
||||
assert.equal(payload.kpis[3].value, '已上线')
|
||||
assert.equal(payload.kpis[4].meta, '参与扫描')
|
||||
})
|
||||
|
||||
test('risk rule flow detail keeps left explanation and right static diagram titles', () => {
|
||||
assert.match(flowDiagram, /class="risk-rule-flow-explainer"[\s\S]*<strong>流程解释<\/strong>/)
|
||||
assert.match(flowDiagram, /class="risk-rule-flow-visual"[\s\S]*<strong>流程图<\/strong>/)
|
||||
assert.match(flowDiagram, /grid-template-columns:\s*minmax\(260px,\s*0\.78fr\)\s*minmax\(0,\s*1\.22fr\)/)
|
||||
assert.match(flowDiagram, /class="risk-rule-section-title risk-rule-flow-visual-title"/)
|
||||
assert.doesNotMatch(flowDiagram, /<button[\s\S]*zoom/i)
|
||||
})
|
||||
|
||||
test('risk rule test dialog shows field pipeline and revision actions are wired', () => {
|
||||
assert.match(testDialogDisplay, /title:\s*'OCR 原始字段'/)
|
||||
assert.match(testDialogDisplay, /title:\s*'Hermes 规范化字段'/)
|
||||
assert.match(testDialogDisplay, /title:\s*'执行器实际输入'/)
|
||||
assert.match(testDialog, /正在调用规则执行器识别风险/)
|
||||
assert.match(auditView, /<span>创建修订版本<\/span>/)
|
||||
assert.match(auditRuleDialogs, /riskRuleEditMode === 'revision' \? '创建修订版本' : '编辑风险规则'/)
|
||||
assert.match(auditRuleDialogs, /<span>修订原因<\/span>/)
|
||||
})
|
||||
155
web/tests/risk-visibility.test.mjs
Normal file
155
web/tests/risk-visibility.test.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildDetailAlerts } from '../src/utils/detailAlerts.js'
|
||||
import {
|
||||
canViewRiskForContext,
|
||||
filterRiskCardsForVisibility,
|
||||
resolveRiskActionability,
|
||||
resolveRiskDomain,
|
||||
resolveRiskVisibilityScope
|
||||
} from '../src/utils/riskVisibility.js'
|
||||
|
||||
const submitter = {
|
||||
id: 'EMP-001',
|
||||
employeeId: 'EMP-001',
|
||||
name: '张三',
|
||||
roleCodes: []
|
||||
}
|
||||
const budgetManager = {
|
||||
id: 'EMP-P8',
|
||||
name: '预算管理员',
|
||||
grade: 'P8',
|
||||
departmentName: '研发部',
|
||||
roleCodes: ['budget_monitor']
|
||||
}
|
||||
const financeUser = {
|
||||
id: 'FIN-001',
|
||||
name: '财务',
|
||||
roleCodes: ['finance']
|
||||
}
|
||||
|
||||
const applicationRequest = {
|
||||
id: 'AP-202606010001',
|
||||
claimId: 'application-claim-id',
|
||||
documentTypeCode: 'application',
|
||||
approvalKey: 'in_progress',
|
||||
node: '预算管理者审批',
|
||||
employeeId: 'EMP-001',
|
||||
departmentName: '研发部',
|
||||
typeCode: 'travel_application',
|
||||
typeLabel: '差旅费用申请',
|
||||
reason: '北京出差申请',
|
||||
location: '北京',
|
||||
occurredDisplay: '2026-06-01 至 2026-06-03',
|
||||
amountValue: 10000,
|
||||
riskFlags: [
|
||||
{
|
||||
source: 'budget_control',
|
||||
severity: 'high',
|
||||
label: '预算可用余额不足',
|
||||
message: '当前部门预算余额不足。',
|
||||
business_stage: 'expense_application',
|
||||
risk_domain: 'budget',
|
||||
visibility_scope: 'budget_manager',
|
||||
actionability: 'budget_governance'
|
||||
}
|
||||
],
|
||||
expenseItems: []
|
||||
}
|
||||
|
||||
test('application submitter cannot see budget governance alerts in detail topbar', () => {
|
||||
const alerts = buildDetailAlerts(applicationRequest, { currentUser: submitter })
|
||||
assert.deepEqual(alerts.map((item) => item.label), ['SLA 催单次数 0'])
|
||||
})
|
||||
|
||||
test('budget manager can see application budget governance alerts', () => {
|
||||
const alerts = buildDetailAlerts(applicationRequest, { currentUser: budgetManager })
|
||||
assert.equal(alerts[0].label, '高风险 1 项')
|
||||
assert.equal(alerts[0].tone, 'danger')
|
||||
})
|
||||
|
||||
test('reimbursement submitter sees only fixable claim risks', () => {
|
||||
const request = {
|
||||
id: 'RE-202606010001',
|
||||
claimId: 'reimbursement-claim-id',
|
||||
documentTypeCode: 'claim',
|
||||
approvalKey: 'draft',
|
||||
node: '待提交',
|
||||
employeeId: 'EMP-001',
|
||||
typeCode: 'travel',
|
||||
expenseItems: []
|
||||
}
|
||||
const cards = [
|
||||
{
|
||||
id: 'ticket-date',
|
||||
businessStage: 'reimbursement',
|
||||
tone: 'high',
|
||||
risk: '票据日期早于申请行程。',
|
||||
risk_domain: 'trip',
|
||||
visibility_scope: 'submitter',
|
||||
actionability: 'fixable_by_submitter'
|
||||
},
|
||||
{
|
||||
id: 'budget-detail',
|
||||
businessStage: 'reimbursement',
|
||||
tone: 'medium',
|
||||
risk: '预算占用率超过 90%。',
|
||||
risk_domain: 'budget',
|
||||
visibility_scope: 'budget_manager',
|
||||
actionability: 'budget_governance'
|
||||
}
|
||||
]
|
||||
|
||||
const visibleCards = filterRiskCardsForVisibility(cards, { request, currentUser: submitter })
|
||||
assert.deepEqual(visibleCards.map((card) => card.id), ['ticket-date'])
|
||||
})
|
||||
|
||||
test('finance can see reimbursement compliance risks but not budget governance detail', () => {
|
||||
const request = {
|
||||
id: 'RE-202606010002',
|
||||
documentTypeCode: 'claim',
|
||||
approvalKey: 'in_progress',
|
||||
node: '财务审批',
|
||||
employeeId: 'EMP-001',
|
||||
typeCode: 'travel',
|
||||
expenseItems: []
|
||||
}
|
||||
assert.equal(
|
||||
canViewRiskForContext(
|
||||
{
|
||||
businessStage: 'reimbursement',
|
||||
risk: '发票抬头与报销主体不一致。',
|
||||
risk_domain: 'invoice',
|
||||
actionability: 'fixable_by_submitter'
|
||||
},
|
||||
{ request, currentUser: financeUser }
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
canViewRiskForContext(
|
||||
{
|
||||
businessStage: 'reimbursement',
|
||||
risk: '预算余额不足。',
|
||||
risk_domain: 'budget',
|
||||
actionability: 'budget_governance'
|
||||
},
|
||||
{ request, currentUser: financeUser }
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('legacy risk text falls back to semantic visibility defaults', () => {
|
||||
const legacyFlag = {
|
||||
source: 'submission_review',
|
||||
severity: 'high',
|
||||
message: '住宿发票城市与行程城市不一致。',
|
||||
business_stage: 'reimbursement'
|
||||
}
|
||||
|
||||
assert.equal(resolveRiskDomain(legacyFlag), 'trip')
|
||||
assert.equal(resolveRiskActionability(legacyFlag, { businessStage: 'reimbursement' }), 'fixable_by_submitter')
|
||||
assert.equal(resolveRiskVisibilityScope(legacyFlag, { businessStage: 'reimbursement' }), 'submitter')
|
||||
})
|
||||
@@ -15,6 +15,14 @@ const digitalEmployeeList = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/audit/DigitalEmployeeListPanel.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const digitalEmployeesView = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/DigitalEmployeesView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const digitalEmployeesViewModel = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/digitalEmployeesViewModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const digitalEmployeeStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/digital-employees-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -34,3 +42,36 @@ test('digital employee skill name keeps text avatar prefix', () => {
|
||||
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\);/)
|
||||
})
|
||||
|
||||
test('digital employee skill list uses real pagination', () => {
|
||||
assert.match(digitalEmployeeList, /:show-pagination="!loading && !errorMessage && visibleEmployees\.length > 0"/)
|
||||
assert.match(digitalEmployeeList, /:show-page-size="true"/)
|
||||
assert.match(digitalEmployeeList, /:page-size-options="pageSizeOptions"/)
|
||||
assert.match(digitalEmployeeList, /@page-size-change="changePageSize"/)
|
||||
assert.match(digitalEmployeeList, /v-for="employee in pagedEmployees"/)
|
||||
assert.match(digitalEmployeeList, /const pageSize = ref\(10\)/)
|
||||
assert.match(digitalEmployeeList, /label: '10 条\/页', value: 10/)
|
||||
assert.match(digitalEmployeeList, /label: '20 条\/页', value: 20/)
|
||||
assert.match(digitalEmployeeList, /label: '50 条\/页', value: 50/)
|
||||
assert.match(digitalEmployeeList, /每页 \$\{pageSize\.value\} 条/)
|
||||
assert.match(digitalEmployeeList, /function changePageSize\(size\) \{[\s\S]*?currentPage\.value = 1/)
|
||||
})
|
||||
|
||||
test('digital employee skill type filter is wired through list and model', () => {
|
||||
assert.match(digitalEmployeeList, /id="skillCategory"/)
|
||||
assert.match(digitalEmployeeList, /:label="selectedSkillCategoryLabel"/)
|
||||
assert.match(digitalEmployeeList, /:options="skillCategoryOptions"/)
|
||||
assert.match(digitalEmployeeList, /:selected-value="selectedSkillCategory"/)
|
||||
assert.match(digitalEmployeeList, /selectFilter\('skillCategory', \$event\)/)
|
||||
assert.match(digitalEmployeeList, /props\.selectedSkillCategory/)
|
||||
|
||||
assert.match(digitalEmployeesView, /:selected-skill-category="selectedSkillCategory"/)
|
||||
assert.match(digitalEmployeesView, /:skill-category-options="skillCategoryOptions"/)
|
||||
assert.match(digitalEmployeesView, /selectedSkillCategory: selectedSkillCategory\.value/)
|
||||
assert.match(digitalEmployeesView, /if \(type === 'skillCategory'\)[\s\S]*?selectedSkillCategory\.value = value/)
|
||||
assert.match(digitalEmployeesView, /selectedSkillCategory\.value = ''/)
|
||||
|
||||
assert.match(digitalEmployeesViewModel, /selectedSkillCategory = normalizeText\(filters\.selectedSkillCategory\)/)
|
||||
assert.match(digitalEmployeesViewModel, /hasSkillCategory/)
|
||||
assert.match(digitalEmployeesViewModel, /normalizeText\(item\.skillCategory\) !== selectedSkillCategory/)
|
||||
})
|
||||
|
||||
@@ -359,6 +359,8 @@ test('guided flow is local until final confirmation or collected query handoff',
|
||||
assert.doesNotMatch(guidedFlowScript, /review_action:\s*['"]save_draft['"]/)
|
||||
assert.match(guidedFlowScript, /fetchExpenseClaims/)
|
||||
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
|
||||
assert.match(guidedFlowScript, /if \(!applications\.length\) \{[\s\S]*guidedFlowState\.value = createEmptyGuidedFlowState\(\)[\s\S]*meta: \['缺少可关联申请单'\][\s\S]*\}\)/)
|
||||
assert.doesNotMatch(guidedFlowScript, /meta: \['缺少可关联申请单'\],[\s\S]{0,120}suggestedActions: buildGuidedExpenseTypeActions\(\)/)
|
||||
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
|
||||
assert.match(createViewScript, /handleSceneSelectionApplicationGate/)
|
||||
assert.match(createViewScript, /if \(await handleGuidedComposerSubmit\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*return submitComposerInternal\(options\)/)
|
||||
|
||||
@@ -406,20 +406,22 @@ test('review drawer save action is disabled while receipt recognition is submitt
|
||||
)
|
||||
})
|
||||
|
||||
test('draft creation starts composer attachment persistence after response rendering', () => {
|
||||
test('draft creation keeps detail-scoped attachment persistence alive before close', () => {
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/void syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\.then\(\(syncResult\) => \{[\s\S]*persistSessionState\(\)[\s\S]*emitRequestUpdated\?\.\(\{/s
|
||||
/const persistComposerFilesToDraft = async \(\) => \{[\s\S]*const syncResult = await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)[\s\S]*persistSessionState\(\)[\s\S]*if \(detailScopedUpload\) \{[\s\S]*emitRequestUpdated\?\.\(\{/s
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)/
|
||||
/const persistTask = persistComposerFilesToDraft\(\)[\s\S]*if \(detailScopedUpload\) \{[\s\S]*await persistTask[\s\S]*\} else \{[\s\S]*void persistTask[\s\S]*\}/s
|
||||
)
|
||||
assert.ok(
|
||||
submitComposerScript.indexOf('replaceMessage(pendingMessage.id, assistantMessage)') <
|
||||
submitComposerScript.indexOf('void syncComposerFilesToDraft(resolvedDraftClaimId, files)'),
|
||||
submitComposerScript.indexOf('const persistTask = persistComposerFilesToDraft()'),
|
||||
'assistant response should render before background attachment persistence starts'
|
||||
)
|
||||
assert.match(submitComposerScript, /source: 'detail-smart-entry-attachment-sync'/)
|
||||
assert.match(submitComposerScript, /uploadedCount: Number\(syncResult\?\.uploadedCount \|\| 0\)/)
|
||||
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
|
||||
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
||||
assert.match(
|
||||
|
||||
@@ -88,10 +88,13 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.match(detailScript, /canProcessBudgetApprovalStage\.value/)
|
||||
assert.doesNotMatch(detailScript, /leaderApprovalReadonlyText/)
|
||||
assert.match(detailScript, /resolveGeneratedDraftClaimNo/)
|
||||
assert.match(detailScript, /resolveApproveErrorMessage/)
|
||||
assert.match(detailScript, /当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。/)
|
||||
assert.match(detailScript, /approveActionLabel/)
|
||||
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\) \|\| '同意'/)
|
||||
assert.match(detailScript, /报销草稿 \$\{generatedDraftClaimNo\} 已生成/)
|
||||
assert.match(detailScript, /流转至预算管理者审批/)
|
||||
assert.match(detailScript, /按预算与风险结果决定下一步/)
|
||||
assert.match(detailScript, /无风险且预算充足将直接完成申请/)
|
||||
|
||||
assert.doesNotMatch(detailTemplate, /v-if="showLeaderApprovalPanel"/)
|
||||
assert.doesNotMatch(detailTemplate, /showApplicationLeaderOpinionInput/)
|
||||
@@ -134,6 +137,7 @@ test('approval-mode detail collects leader opinion inside confirm dialog before
|
||||
assert.doesNotMatch(handleApproveRequest, /approveExpenseClaim/)
|
||||
assert.doesNotMatch(handleApproveRequest, /leaderOpinion\.value\.trim/)
|
||||
assert.match(confirmApproveRequest, /approveExpenseClaim/)
|
||||
assert.match(confirmApproveRequest, /emit\('request-updated', \{ claimId: request\.value\.claimId \}\)[\s\S]*emit\('backToRequests'\)/)
|
||||
assert.doesNotMatch(confirmApproveRequest, /requiresApprovalOpinion\.value && !leaderOpinion\.value\.trim\(\)/)
|
||||
assert.doesNotMatch(confirmApproveRequest, /请先填写领导意见,填写后才能确认审核。/)
|
||||
|
||||
|
||||
@@ -7,6 +7,10 @@ const detailStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const responsiveStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view-part2.css', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -25,3 +29,10 @@ test('detail hero facts keep document number and date on one row on laptop scree
|
||||
assert.match(detailStyles, /\.application-detail-fact \{[\s\S]*grid-template-columns:\s*minmax\(96px,\s*28%\) minmax\(0,\s*1fr\)/)
|
||||
assert.doesNotMatch(detailScript, /key:\s*'status'[\s\S]*label:\s*'当前状态'/)
|
||||
})
|
||||
|
||||
test('reimbursement progress title and long linked application status are compact', () => {
|
||||
assert.match(detailTemplate, /isApplicationDocument \? '申请进度' : '报销进度'/)
|
||||
assert.doesNotMatch(detailTemplate, /差旅进度/)
|
||||
assert.match(detailStyles, /\.progress-step-status \{[\s\S]*width:\s*100%;[\s\S]*max-width:\s*136px;[\s\S]*min-width:\s*0;/)
|
||||
assert.match(detailStyles, /\.progress-step-status \{[\s\S]*white-space:\s*nowrap;[\s\S]*overflow:\s*hidden;[\s\S]*text-overflow:\s*ellipsis;/)
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildClaimSummaryRiskCards,
|
||||
buildItemClaimRiskState,
|
||||
extractRiskTagsFromText,
|
||||
filterRiskCardsByBusinessStage,
|
||||
resolveRiskTags,
|
||||
resolveRiskTagTone
|
||||
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||
@@ -28,6 +29,10 @@ const detailViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailViewInsights = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailInsights.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailViewStyle = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -48,6 +53,10 @@ const returnReasonDialog = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/shared/ReturnReasonDialog.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stageRiskAdviceCard = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/StageRiskAdviceCard.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const attachmentMeta = {
|
||||
file_name: 'taxi-invoice.pdf',
|
||||
@@ -172,12 +181,31 @@ test('AI advice keeps visible risk flags when backend uses tone instead of sever
|
||||
assert.ok(riskCards[0].suggestion.includes('员工档案'))
|
||||
})
|
||||
|
||||
test('risk card badge only shows severity while title keeps business risk name', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'attachment_analysis',
|
||||
severity: 'high',
|
||||
label: '票据日期超出差旅行程高风险',
|
||||
message: '酒店发票日期为 2 月,晚于已批准 6 月差旅行程范围。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.equal(riskCards[0].tone, 'high')
|
||||
assert.equal(riskCards[0].label, '高风险')
|
||||
assert.equal(riskCards[0].title, '票据日期超出差旅行程高风险')
|
||||
})
|
||||
|
||||
test('AI advice falls back to claim risk summary instead of showing an empty risk area', () => {
|
||||
const riskCards = buildClaimSummaryRiskCards({
|
||||
riskSummary: 'AI预审发现 1 条中风险附件,已随单流转给审批人复核。'
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
assert.equal(riskCards[0].businessStage, 'reimbursement')
|
||||
assert.equal(riskCards[0].tone, 'medium')
|
||||
assert.equal(riskCards[0].label, '中风险')
|
||||
assert.match(riskCards[0].risk, /中风险附件/)
|
||||
@@ -185,6 +213,81 @@ test('AI advice falls back to claim risk summary instead of showing an empty ris
|
||||
assert.ok(riskCards[0].suggestion.includes('附件预览'))
|
||||
})
|
||||
|
||||
test('risk cards carry structured business stage for approval advice filtering', () => {
|
||||
const applicationCards = buildAttachmentRiskCards({
|
||||
businessStage: 'expense_application',
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'policy_review',
|
||||
severity: 'medium',
|
||||
label: '预算风险',
|
||||
message: '申请金额可能占用预算余额,需要预算管理者复核。'
|
||||
}
|
||||
]
|
||||
})
|
||||
const reimbursementCards = buildAttachmentRiskCards({
|
||||
businessStage: 'expense_application',
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'attachment_analysis',
|
||||
business_stage: 'reimbursement',
|
||||
severity: 'high',
|
||||
label: '票据风险',
|
||||
message: '报销票据城市与行程城市不一致。'
|
||||
}
|
||||
]
|
||||
})
|
||||
const legacyAttachmentCards = buildAttachmentRiskCards({
|
||||
businessStage: 'expense_application',
|
||||
claimRiskFlags: [
|
||||
{
|
||||
source: 'attachment_analysis',
|
||||
severity: 'medium',
|
||||
label: '附件风险',
|
||||
message: '差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。'
|
||||
}
|
||||
]
|
||||
})
|
||||
const summaryCards = buildClaimSummaryRiskCards({
|
||||
documentTypeCode: 'application',
|
||||
riskSummary: '预算余额不足,申请需要复核。'
|
||||
})
|
||||
const attachmentSummaryCards = buildClaimSummaryRiskCards({
|
||||
documentTypeCode: 'application',
|
||||
businessStage: 'expense_application',
|
||||
riskSummary: '差旅附件暂未识别到有效票据信息,请重新上传清晰附件或人工补录。'
|
||||
})
|
||||
|
||||
assert.equal(applicationCards.length, 1)
|
||||
assert.equal(applicationCards[0].businessStage, 'expense_application')
|
||||
assert.equal(reimbursementCards.length, 1)
|
||||
assert.equal(reimbursementCards[0].businessStage, 'reimbursement')
|
||||
assert.equal(legacyAttachmentCards.length, 1)
|
||||
assert.equal(legacyAttachmentCards[0].businessStage, 'reimbursement')
|
||||
assert.deepEqual(
|
||||
filterRiskCardsByBusinessStage(
|
||||
[...applicationCards, ...reimbursementCards, ...legacyAttachmentCards],
|
||||
'expense_application'
|
||||
).map((card) => card.risk),
|
||||
['申请金额可能占用预算余额,需要预算管理者复核。']
|
||||
)
|
||||
assert.equal(summaryCards.length, 1)
|
||||
assert.equal(summaryCards[0].businessStage, 'expense_application')
|
||||
assert.equal(attachmentSummaryCards.length, 1)
|
||||
assert.equal(attachmentSummaryCards[0].businessStage, 'reimbursement')
|
||||
assert.deepEqual(filterRiskCardsByBusinessStage(attachmentSummaryCards, 'expense_application'), [])
|
||||
})
|
||||
|
||||
test('stage risk advice card exposes direct reviewer action suggestion', () => {
|
||||
assert.match(stageRiskAdviceCard, /class="employee-risk-action"/)
|
||||
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
|
||||
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
|
||||
assert.match(stageRiskAdviceCard, /compactAdviceItems/)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
||||
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
|
||||
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
|
||||
})
|
||||
|
||||
test('AI advice ignores approval opinions and flow logs as risks', () => {
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
claimRiskFlags: [
|
||||
@@ -292,13 +395,32 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
|
||||
advice.sections.map((section) => ({ title: section.title, kind: section.kind })),
|
||||
[
|
||||
{ title: '建议补充字段', kind: 'completion' },
|
||||
{ title: '已知存在风险', kind: 'risk' }
|
||||
{ title: '已知存在风险(1项)', kind: 'risk' }
|
||||
]
|
||||
)
|
||||
assert.deepEqual(advice.sections[0].items, ['补充业务地点', '补充报销金额'])
|
||||
assert.equal(advice.sections[1].items.length, 1)
|
||||
})
|
||||
|
||||
test('AI advice view model sorts and displays every risk card', () => {
|
||||
const riskCards = [
|
||||
{ id: 'risk-1', tone: 'medium', label: '中风险', title: '中风险一', risk: '中风险一。' },
|
||||
{ id: 'risk-2', tone: 'high', label: '高风险', title: '高风险一', risk: '高风险一。' },
|
||||
{ id: 'risk-3', tone: 'medium', label: '中风险', title: '中风险二', risk: '中风险二。' },
|
||||
{ id: 'risk-4', tone: 'high', label: '高风险', title: '高风险二', risk: '高风险二。' }
|
||||
]
|
||||
const advice = buildAiAdviceViewModel({
|
||||
completionItems: [],
|
||||
riskCards
|
||||
})
|
||||
const riskSection = advice.sections.find((section) => section.kind === 'risk')
|
||||
|
||||
assert.equal(advice.riskCards.length, 4)
|
||||
assert.equal(riskSection.items.length, 4)
|
||||
assert.equal(riskSection.hiddenCount, undefined)
|
||||
assert.deepEqual(riskSection.items.map((item) => item.id), ['risk-2', 'risk-4', 'risk-1', 'risk-3'])
|
||||
})
|
||||
|
||||
test('AI advice view model omits empty sections', () => {
|
||||
const readyAdvice = buildAiAdviceViewModel({
|
||||
completionItems: [],
|
||||
@@ -326,15 +448,25 @@ test('AI advice view model omits empty sections', () => {
|
||||
assert.deepEqual(readyAdvice.sections, [])
|
||||
assert.equal(readyAdvice.badge, '可直接提交')
|
||||
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
|
||||
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险'])
|
||||
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险(1项)'])
|
||||
})
|
||||
|
||||
test('AI advice template renders grouped section titles with completion before risk', () => {
|
||||
assert.match(detailViewTemplate, /v-if="showAiAdvicePanel" class="detail-card panel validation-card"/)
|
||||
assert.match(detailViewTemplate, /<h3>\{\{ aiAdviceTitle \}\}<\/h3>/)
|
||||
assert.match(detailViewTemplate, /<p>\{\{ aiAdviceHint \}\}<\/p>/)
|
||||
assert.match(detailViewScript, /buildClaimSummaryRiskCards\(request\.value\)/)
|
||||
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => isEditableRequest\.value \|\| aiAdvice\.value\.riskCards\.length > 0\)/)
|
||||
assert.match(detailViewScript, /businessStage: currentBusinessStage/)
|
||||
assert.match(detailViewScript, /filterRiskCardsByBusinessStage/)
|
||||
assert.match(detailViewScript, /const summaryRiskCards = filterRiskCardsByBusinessStage/)
|
||||
assert.match(detailViewScript, /buildClaimSummaryRiskCards\(\{/)
|
||||
assert.match(detailViewScript, /const canViewApprovalRiskAdvice = computed/)
|
||||
assert.match(detailViewScript, /!isCurrentApplicant\.value/)
|
||||
assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
|
||||
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
|
||||
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
|
||||
assert.match(detailViewScript, /return '报销风险提示'/)
|
||||
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
|
||||
assert.match(detailViewScript, /hasAiPreReviewResult\.value/)
|
||||
assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
|
||||
assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
|
||||
assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
|
||||
@@ -348,16 +480,24 @@ test('AI advice template renders grouped section titles with completion before r
|
||||
|
||||
test('AI advice risk section uses compact card styling hooks', () => {
|
||||
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
|
||||
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
|
||||
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
|
||||
assert.doesNotMatch(detailViewTemplate, /card\.tags\?\.length/)
|
||||
assert.doesNotMatch(detailViewTemplate, /risk-card-tag-list/)
|
||||
assert.doesNotMatch(detailViewTemplate, /risk-note-tag/)
|
||||
assert.match(detailViewScript, /tags: resolveRiskTags\(card\)/)
|
||||
assert.match(detailViewInsights, /const sortedRiskCards = sortRiskCardsByTone\(normalizedRiskCards\)/)
|
||||
assert.doesNotMatch(detailViewInsights, /visibleRiskCards/)
|
||||
assert.doesNotMatch(detailViewInsights, /hiddenCount/)
|
||||
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-list \{\s*display: grid;\s*gap: 8px;\s*max-height: 360px;/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*position: relative;\s*display: grid;\s*grid-template-columns: minmax\(0, 1\.1fr\) minmax\(220px, \.9fr\);/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
|
||||
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
|
||||
assert.doesNotMatch(detailViewStyle, /\.risk-note-tag/)
|
||||
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
|
||||
assert.match(detailViewStyle, /\.risk-advice-compact-meta span,\s*\.risk-advice-compact-meta em \{\s*margin: 0;/)
|
||||
assert.doesNotMatch(detailViewStyle, /\.risk-advice-more/)
|
||||
})
|
||||
|
||||
test('expense rows show a major-risk warning icon before time', () => {
|
||||
@@ -534,7 +674,7 @@ test('travel detail AI advice adds low risk reminders for optional receipts', ()
|
||||
assert.match(detailViewScript, /可以继续补充票据报销/)
|
||||
assert.match(
|
||||
detailViewScript,
|
||||
/\.\.\.buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)/
|
||||
/const optionalRiskCards = filterRiskCardsByBusinessStage\([\s\S]*buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)[\s\S]*currentBusinessStage/
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,10 @@ const detailExpenseModelScript = readFileSync(
|
||||
)
|
||||
|
||||
function extractFunction(source, name) {
|
||||
const signatureIndex = source.indexOf(`function ${name}(`)
|
||||
let signatureIndex = source.indexOf(`function ${name}(`)
|
||||
if (signatureIndex === -1) {
|
||||
signatureIndex = source.indexOf(`async function ${name}(`)
|
||||
}
|
||||
assert.notEqual(signatureIndex, -1, `${name} should exist`)
|
||||
|
||||
const bodyStart = source.indexOf('{', signatureIndex)
|
||||
@@ -40,11 +43,13 @@ function extractFunction(source, name) {
|
||||
}
|
||||
|
||||
test('detail submit opens a confirmation dialog before calling submit API', () => {
|
||||
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*confirm-text="确认提交"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
|
||||
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
|
||||
assert.match(detailViewTemplate, /cancel-text="返回核对"/)
|
||||
assert.match(detailViewTemplate, /@click="handleSubmit"/)
|
||||
|
||||
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
|
||||
assert.match(detailViewScript, /const submitActionLabel = computed/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = false/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen,/)
|
||||
@@ -54,6 +59,7 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
|
||||
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
|
||||
assert.doesNotMatch(handleSubmit, /submitExpenseClaim/)
|
||||
assert.match(handleSubmit, /runAiPreReview\(\)/)
|
||||
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
|
||||
})
|
||||
|
||||
@@ -84,3 +90,9 @@ test('archived detail delete action is gated by admin-only permission', () => {
|
||||
assert.match(detailViewTemplate, /v-else-if="canReturnRequest \|\| canApproveRequest \|\| canPayRequest \|\| canDeleteRequest"/)
|
||||
assert.doesNotMatch(detailViewTemplate, /v-if="canManageCurrentClaim"/)
|
||||
})
|
||||
|
||||
test('editable detail delete action is limited to applicant or claim manager', () => {
|
||||
assert.match(detailViewScript, /const isCurrentApplicant = computed/)
|
||||
assert.match(detailViewScript, /if \(canManageCurrentClaim\.value\) {\s*return true\s*}/)
|
||||
assert.match(detailViewScript, /return isEditableRequest\.value && isCurrentApplicant\.value/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user