feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -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
)
})

View File

@@ -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')
}

View File

@@ -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: '直属领导审批',

View 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)
})

View File

@@ -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/)
})

View File

@@ -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: [

View File

@@ -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), '已更新出行方式和费用测算。')
})

View File

@@ -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() {

View File

@@ -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()

View File

@@ -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/)

View 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>/)
})

View 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')
})

View File

@@ -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/)
})

View File

@@ -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\)/)

View File

@@ -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(

View File

@@ -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, /请先填写领导意见,填写后才能确认审核。/)

View File

@@ -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;/)
})

View File

@@ -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/
)
})

View File

@@ -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/)
})