feat(web): 工作台 AI 模式报销预审与文档查询模型拆分

- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出
- PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示
- 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试
- 新增 AI 文档卡片背景资源
This commit is contained in:
caoxiaozhu
2026-06-20 10:17:37 +08:00
parent 3d69f8501f
commit 304bbe1fd4
26 changed files with 3974 additions and 117 deletions

View File

@@ -0,0 +1,114 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildAiApplicationPrecheck,
buildAiApplicationPrecheckMessage,
buildAiApplicationPrecheckThinkingEvents
} from '../src/utils/aiApplicationPrecheckModel.js'
const preview = {
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
amount: '2,120元',
days: '4天',
transportMode: '火车'
},
missingFields: []
}
test('application precheck blocks application generation when existing application overlaps', () => {
const precheck = buildAiApplicationPrecheck(preview, {
currentUser: { name: '曹笑竹', departmentName: '技术部' },
claimsPayload: {
items: [
{
claim_no: 'AP-OVERLAP',
document_type: 'expense_application',
expense_type: 'travel_application',
employee_name: '曹笑竹',
status: 'submitted',
risk_flags_json: [
{
source: 'application_detail',
application_detail: {
business_time: '2026-02-21 至 2026-02-22',
reason: '同时间段现场支持',
location: '上海'
}
}
]
}
]
},
budgetSummary: {
total_amount: 10000,
reserved_amount: 8000,
consumed_amount: 500,
available_amount: 1500
}
})
assert.equal(precheck.overlap.status, 'warning')
assert.match(precheck.overlap.summary, /可能重叠/)
assert.equal(precheck.overlap.matches[0].claimNo, 'AP-OVERLAP')
assert.equal(precheck.budget.status, 'warning')
assert.equal(precheck.budget.requiresBudgetReview, true)
assert.match(precheck.budget.summary, /预算管理者审核/)
const message = buildAiApplicationPrecheckMessage(preview, precheck)
assert.match(message, /### 发现同时间段已有申请单/)
assert.match(message, /时间重叠提醒/)
assert.match(message, /AP-OVERLAP/)
assert.match(message, /\| 单据编号 \| 申请时间 \| 状态 \| 事由 \| 操作 \|/)
assert.match(message, /\| AP-OVERLAP \| 2026-02-21 至 2026-02-22 \| 审批中 \| 同时间段现场支持 \| \[查看\]\(#ai-open-application-detail:AP-OVERLAP\) \|/)
assert.match(message, /2026-02-21 至 2026-02-22/)
assert.match(message, /同时间段现场支持/)
assert.match(message, /请先检查本次申请时间是否填写正确/)
assert.doesNotMatch(message, /出差申请表草稿已生成/)
})
test('application precheck emits thinking events for overlap, budget, and form generation', () => {
const precheck = buildAiApplicationPrecheck(preview, {
currentUser: { name: '曹笑竹' },
claimsPayload: [],
budgetSummary: {
total_amount: 10000,
reserved_amount: 1000,
consumed_amount: 1000,
available_amount: 8000
}
})
const events = buildAiApplicationPrecheckThinkingEvents(precheck)
assert.equal(events.length, 3)
assert.deepEqual(
events.map((event) => event.eventId),
['application-precheck-overlap', 'application-precheck-budget', 'application-precheck-form']
)
assert.match(events[1].content, /预算/)
})
test('application precheck ignores application candidates without parseable business time', () => {
const precheck = buildAiApplicationPrecheck(preview, {
currentUser: { name: '曹笑竹' },
claimsPayload: {
items: [
{
claim_no: 'AP-NO-TIME',
document_type: 'expense_application',
expense_type: 'travel_application',
employee_name: '曹笑竹',
status: 'submitted'
}
]
},
budgetSummary: {}
})
assert.equal(precheck.overlap.status, 'ok')
assert.deepEqual(precheck.overlap.matches, [])
})

View File

@@ -0,0 +1,127 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT,
buildAiApplicationPreviewActionPayload
} from '../src/services/aiApplicationPreviewActions.js'
import {
applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
const applicationPreview = {
fields: {
applicationType: '差旅费用申请',
applicant: '曹笑竹',
grade: 'P5',
department: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
lodgingDailyCap: '250元/天',
subsidyDailyCap: '100元/天',
transportPolicy: '按交通费用预估表暂估',
policyEstimate: '交通 720元 + 住宿 1,000元 + 补贴 400元 = 2,120元4天',
amount: '2,120元'
}
}
const currentUser = {
username: 'caoxiaozhu@xf.com',
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
grade: 'P5',
managerName: '向万红',
roleCodes: ['employee']
}
test('save application preview payload uses save draft action without submit wording', () => {
const payload = buildAiApplicationPreviewActionPayload({
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
applicationPreview,
currentUser,
conversationId: 'inline-1'
})
assert.equal(payload.user_id, 'caoxiaozhu@xf.com')
assert.equal(payload.conversation_id, 'inline-1')
assert.equal(payload.context_json.session_type, 'application')
assert.equal(payload.context_json.review_action, undefined)
assert.equal(payload.context_json.application_action, 'save_draft')
assert.equal(payload.context_json.application_preview.fields.transportMode, '火车')
assert.match(payload.message, /费用申请保存草稿/)
assert.match(payload.message, /保存草稿/)
assert.doesNotMatch(payload.message, /确认提交/)
})
test('submit application preview payload keeps existing draft id for resubmission', () => {
const payload = buildAiApplicationPreviewActionPayload({
actionType: AI_APPLICATION_ACTION_SUBMIT,
applicationPreview,
currentUser,
conversationId: 'inline-1',
draftPayload: {
claim_id: 'draft-001',
claim_no: 'AP-202602200001'
}
})
assert.equal(payload.context_json.review_action, undefined)
assert.equal(payload.context_json.application_edit_claim_id, 'draft-001')
assert.equal(payload.context_json.draft_claim_id, 'draft-001')
assert.match(payload.message, /费用申请确认提交/)
assert.match(payload.message, /确认提交/)
})
test('travel application preview calculates base standards before transport mode is selected', () => {
const preview = buildLocalApplicationPreview(
'2月20-23日去上海出差辅助国网仿生产服务器部署',
{ name: '曹笑竹', grade: 'P5', location: '武汉' },
{ today: '2026-06-20' }
)
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' })
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, {
days: 4,
location: '上海',
grade: 'P5',
transport_mode: null,
origin_location: '武汉',
travel_date: '2026-02-20'
})
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 4,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 450,
hotel_amount: 1800,
total_allowance_rate: 100,
allowance_amount: 400,
transport_mode: '火车',
transport_origin: '武汉',
transport_destination: '上海',
transport_estimated_amount: 720,
total_amount: 2200,
rule_name: '公司差旅费报销规则',
rule_version: 'v1.0.0'
}, { grade: 'P5', location: '武汉' })
assert.equal(estimatedPreview.fields.transportMode, '')
assert.equal(estimatedPreview.missingFields.includes('出行方式'), true)
assert.equal(estimatedPreview.fields.lodgingDailyCap, '450元/天')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '100元/天')
assert.equal(estimatedPreview.fields.transportPolicy, '选择火车、飞机或轮船后自动预估交通费用')
assert.equal(estimatedPreview.fields.policyEstimate, '交通待补充 + 住宿 1,800元 + 补贴 400元 = 2,200元4天不含交通')
assert.equal(estimatedPreview.fields.amount, '2,200元不含交通')
})

View File

@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
test('AI conversation renderer turns business copy into spacious semantic HTML', () => {
const rendered = renderAiConversationHtml([
'### 出差申请办理确认',
'',
'**我已在您的输入中提取到关键信息**,如下表所示:',
'',
'> **前置查询结果**:我已查询您名下可关联的差旅申请单,当前未查到可关联单据。',
'',
'> **需要您确认**:发起新的出差申请属于业务操作,需要您手动确认后我再继续办理。',
'',
'点击下方 **确认发起出差申请** 后,我会继续完成:',
'',
'- **单据重叠核查**:检查同一时间段是否已有申请单,避免重复申请。',
'- **预算与审批预审**:查看部门预算影响,判断是否可能增加预算管理者审核。'
].join('\n'))
assert.match(rendered, /<div class="ai-html-flow">/)
assert.match(rendered, /<h3 class="ai-html-title">出差申请办理确认<\/h3>/)
assert.match(rendered, /<section class="ai-html-focus-grid" aria-label="重点信息">/)
assert.match(rendered, /<article class="ai-html-focus-card">[\s\S]*前置查询结果[\s\S]*当前未查到可关联单据/)
assert.match(rendered, /<article class="ai-html-focus-card">[\s\S]*需要您确认[\s\S]*需要您手动确认后我再继续办理/)
assert.match(rendered, /<ul class="ai-html-steps">[\s\S]*单据重叠核查[\s\S]*预算与审批预审/)
assert.doesNotMatch(rendered, /<blockquote>/)
assert.doesNotMatch(rendered, /<ul>\s*<li><strong>/)
})
test('AI conversation renderer supports tables and escapes unsafe HTML', () => {
const rendered = renderAiConversationHtml([
'### 查询结果',
'',
'| 字段 | 内容 |',
'| --- | --- |',
'| 事由 | 辅助 <script>alert(1)</script> 部署 |',
'| 地点 | 上海 |'
].join('\n'))
assert.match(rendered, /<div class="ai-html-table-wrap">/)
assert.match(rendered, /<th>字段<\/th>/)
assert.match(rendered, /&lt;script&gt;alert\(1\)&lt;\/script&gt;/)
assert.doesNotMatch(rendered, /<script>/)
})
test('AI conversation renderer renders application detail action links as buttons', () => {
const rendered = renderAiConversationHtml([
'| 单据编号 | 操作 |',
'| --- | --- |',
'| AP-OVERLAP | [查看](#ai-open-application-detail:AP-OVERLAP) |'
].join('\n'))
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-application"/)
assert.match(rendered, /data-ai-action="open-application-detail"/)
assert.match(rendered, /href="#ai-open-application-detail:AP-OVERLAP"/)
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-application-detail/)
})
test('AI conversation renderer renders document detail action links as buttons', () => {
const rendered = renderAiConversationHtml('[查看单据](#ai-open-document-detail:CL-20260221001)')
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document"/)
assert.match(rendered, /data-ai-action="open-document-detail"/)
assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(rendered, /target="_blank"[\s\S]{0,120}#ai-open-document-detail/)
})
test('AI conversation renderer renders images as html and rejects unsafe image sources', () => {
const rendered = renderAiConversationHtml([
'### 图片材料',
'',
'![票据预览](https://example.com/receipt.png)',
'',
'内联图片:![危险](javascript:alert(1))'
].join('\n'))
assert.match(rendered, /<figure class="ai-html-image-frame">/)
assert.match(rendered, /<img class="ai-html-image" src="https:\/\/example\.com\/receipt\.png" alt="票据预览" loading="lazy" \/>/)
assert.match(rendered, /<figcaption class="ai-html-image-caption">票据预览<\/figcaption>/)
assert.doesNotMatch(rendered, /javascript:alert/)
})
test('AI conversation renderer keeps separated step bullets in one numbered sequence', () => {
const rendered = renderAiConversationHtml([
'点击下方 **确认发起出差申请** 后,我会继续完成:',
'',
'- **单据重叠核查**:检查同一时间段是否已有申请单,避免重复申请。',
'',
'- **预算与审批预审**:查看部门预算影响,判断是否可能增加预算管理者审核。',
'',
'- **申请表生成**:预审完成后,再展示完整申请表并自动预填已识别信息。'
].join('\n'))
assert.equal((rendered.match(/class="ai-html-steps"/g) || []).length, 1)
assert.match(rendered, /<span class="ai-html-step-index">1<\/span>[\s\S]*单据重叠核查/)
assert.match(rendered, /<span class="ai-html-step-index">2<\/span>[\s\S]*预算与审批预审/)
assert.match(rendered, /<span class="ai-html-step-index">3<\/span>[\s\S]*申请表生成/)
})

View File

@@ -0,0 +1,181 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildAiDocumentQueryConditionSummary,
buildAiDocumentQueryMessage,
filterAiDocumentQueryRecords,
resolveAiDocumentQueryIntent
} from '../src/utils/aiDocumentQueryModel.js'
import { renderAiConversationHtml } from '../src/utils/aiConversationHtmlRenderer.js'
const today = '2026-06-20'
const claims = [
{
id: 'claim-1',
claim_no: 'CL-20260221001',
document_type_code: 'reimbursement',
expense_type: 'travel',
status: 'submitted',
reason: '上海出差报销',
employee_name: '曹小筑',
department_name: '交付部',
location: '上海',
occurred_at: '2026-02-21T09:00:00Z',
updated_at: '2026-02-22T10:00:00Z',
amount: 1200
},
{
id: 'app-1',
claim_no: 'AP-20260220001',
document_type_code: 'application',
expense_type: 'travel_application',
status: 'approved',
reason: '辅助国网仿生产服务器部署',
employee_name: '曹小筑',
department_name: '交付部',
location: '上海',
occurred_at: '2026-02-20T09:00:00Z',
updated_at: '2026-02-20T10:00:00Z',
amount: 3000
},
{
id: 'claim-2',
claim_no: 'CL-20260305001',
document_type_code: 'reimbursement',
expense_type: 'office',
status: 'draft',
reason: '办公用品采购',
occurred_at: '2026-03-05T09:00:00Z',
amount: 500
}
]
test('AI document query intent detects my document list questions', () => {
const intent = resolveAiDocumentQueryIntent('我现在有哪些单据?', { today })
assert.equal(intent?.source, 'mine')
assert.equal(intent?.documentType, 'all')
assert.equal(intent?.sourceLabel, '我的单据')
})
test('AI document query intent detects approval document questions', () => {
const intent = resolveAiDocumentQueryIntent('我有哪些审核单', { today })
assert.equal(intent?.source, 'approval')
assert.equal(intent?.sourceLabel, '待我审核的单据')
})
test('AI document query filters by month and document type', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些申请单', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.documentType, 'application')
assert.equal(intent?.timeRange?.start, '2026-02-01')
assert.equal(intent?.timeRange?.end, '2026-02-28')
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260220001'])
})
test('AI document query filters by single day and document type', () => {
const intent = resolveAiDocumentQueryIntent('2月20日发生的申请单有哪些', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.timeRange?.start, '2026-02-20')
assert.equal(intent?.timeRange?.end, '2026-02-20')
assert.deepEqual(records.map((record) => record.documentNo), ['AP-20260220001'])
})
test('AI document query combines natural-language filters', () => {
const intent = resolveAiDocumentQueryIntent('查一下2月审批中的差旅报销单金额超过1000上海相关的单据有哪些', { today })
const records = filterAiDocumentQueryRecords(claims, intent)
assert.equal(intent?.documentType, 'reimbursement')
assert.equal(intent?.timeRange?.label, '2026年2月')
assert.equal(intent?.statusFilter?.label, '审批中')
assert.equal(intent?.expenseTypeFilter?.label, '差旅费')
assert.equal(intent?.keywordFilter?.label, '上海')
assert.equal(intent?.amountFilter?.min, 1000)
assert.deepEqual(records.map((record) => record.documentNo), ['CL-20260221001'])
assert.match(buildAiDocumentQueryConditionSummary(intent), /状态:审批中/)
assert.match(buildAiDocumentQueryConditionSummary(intent), /费用类型:差旅费/)
assert.match(buildAiDocumentQueryConditionSummary(intent), /关键词:上海/)
assert.match(buildAiDocumentQueryConditionSummary(intent), /金额不少于1000元/)
})
test('AI document query excludes undated rows when a time condition is present', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const records = filterAiDocumentQueryRecords([
...claims,
{
id: 'no-date',
claim_no: 'CL-NO-DATE',
document_type_code: 'reimbursement',
expense_type: 'travel',
status: 'submitted',
reason: '缺少业务日期的单据',
amount: 800
}
], intent)
assert.deepEqual(records.map((record) => record.documentNo), ['CL-20260221001', 'AP-20260220001'])
})
test('AI document query message renders html document cards with detail actions', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const message = buildAiDocumentQueryMessage(intent, claims)
assert.match(message, /### 已查询到相关单据/)
assert.match(message, /<!-- ai-trusted-html:start -->/)
assert.match(message, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
// 申请单 app-1 状态为 approved → is-success 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(message, /<header class="ai-document-card__head">/)
assert.match(message, /<span class="ai-document-card__status">已审批<\/span>/)
assert.match(message, /<strong class="ai-document-card__reason">辅助国网仿生产服务器部署<\/strong>/)
assert.match(message, /<span class="ai-document-card__owner">曹小筑<\/span>/)
assert.match(message, /<span class="ai-document-card__dept">交付部<\/span>/)
assert.match(message, /<span class="ai-document-card__number">AP-20260220001<\/span>/)
assert.match(message, /<strong class="ai-document-card__amount">¥3,000\.00<\/strong>/)
assert.match(message, /<div class="ai-document-card__meta">/)
assert.match(message, /<span class="ai-document-card__meta-item">上海<\/span>/)
assert.match(message, /href="#ai-open-document-detail:AP-20260220001"/)
// 报销单 claim-1 状态为 submitted → is-pending 语义类
assert.match(message, /<article class="ai-document-card ai-document-card--reimbursement is-pending" aria-label="单据详情">/)
assert.match(message, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(message, /\| 单据编号 \|/)
assert.doesNotMatch(message, /^> /m)
})
test('AI document query html cards render as trusted card markup', () => {
const intent = resolveAiDocumentQueryIntent('我2月有哪些单据', { today })
const rendered = renderAiConversationHtml(buildAiDocumentQueryMessage(intent, claims))
assert.match(rendered, /<h3 class="ai-html-title">已查询到相关单据<\/h3>/)
assert.match(rendered, /<section class="ai-document-card-list" aria-label="单据查询结果">/)
assert.match(rendered, /<article class="ai-document-card ai-document-card--application is-success" aria-label="单据详情">/)
assert.match(rendered, /class="ai-document-card__head"/)
assert.match(rendered, /class="ai-document-card__meta"/)
assert.match(rendered, /class="ai-document-card__meta-item"/)
assert.match(rendered, /class="ai-html-action-link ai-html-action-link-document ai-document-card__action"/)
assert.match(rendered, /href="#ai-open-document-detail:CL-20260221001"/)
assert.doesNotMatch(rendered, /&lt;section class=&quot;ai-document-card-list/)
assert.doesNotMatch(rendered, /<blockquote>/)
})
test('AI document query trusted html rejects unsafe card markup', () => {
const rendered = renderAiConversationHtml([
'### 查询结果',
'',
'<!-- ai-trusted-html:start -->',
'<section class="ai-document-card-list" aria-label="单据查询结果" onclick="alert(1)">',
'<article class="ai-document-card"><script>alert(1)</script></article>',
'</section>',
'<!-- ai-trusted-html:end -->'
].join('\n'))
assert.match(rendered, /<h3 class="ai-html-title">查询结果<\/h3>/)
assert.doesNotMatch(rendered, /ai-document-card-list/)
assert.doesNotMatch(rendered, /<script>/)
assert.doesNotMatch(rendered, /onclick=/)
})

View File

@@ -10,7 +10,8 @@ import {
extractArchiveMonth,
formatArchiveMonthLabel,
formatArchiveRiskCountLabel,
hasActiveArchiveListFilters
hasActiveArchiveListFilters,
resolveArchiveRiskTone
} from '../src/utils/archiveCenterListFilters.js'
const sampleRows = [
@@ -117,3 +118,35 @@ test('hasActiveArchiveListFilters detects active criteria', () => {
assert.equal(hasActiveArchiveListFilters({ risk: 'high' }), true)
assert.equal(hasActiveArchiveListFilters({ risk: 'all', type: 'all' }), false)
})
test('list risk tone hides budget governance risk from applicant for consistency with detail', () => {
// 申请单列表里,预算治理类风险(对申请人不可见)不应计入申请人看到的风险等级,
// 避免出现“列表显示中风险、详情却看不到”的不一致。
const applicationRequest = {
id: 'AP-202606200001',
documentTypeCode: 'application',
typeCode: 'travel_application',
employeeId: 'EMP-001',
employeeName: '张三'
}
const submitter = { id: 'EMP-001', name: '张三', employeeId: 'EMP-001' }
const riskFlags = [
{
source: 'budget_control',
severity: 'high',
message: '预算可用余额不足。',
business_stage: 'expense_application',
risk_domain: 'budget',
visibility_scope: 'budget_manager',
actionability: 'budget_governance'
}
]
const viewerForSubmitter = { request: applicationRequest, currentUser: submitter }
// 申请人:列表不应显示该预算风险 → tone 为 low、count 为 0
assert.equal(resolveArchiveRiskTone(riskFlags, '', viewerForSubmitter), 'low')
assert.equal(countClaimRisks(riskFlags, '', viewerForSubmitter), 0)
// 不传 viewerOptions 时保持向后兼容(原样统计)
assert.equal(resolveArchiveRiskTone(riskFlags, ''), 'high')
})

View File

@@ -343,7 +343,7 @@ test('documents center list renders risk level tags instead of status tags', ()
assert.match(documentsCenterView, /<th>风险等级<\/th>/)
assert.match(documentsCenterView, /<td data-label="风险等级">[\s\S]*class="risk-level-tags"[\s\S]*v-for="tag in row\.riskTags"/)
assert.match(documentsCenterView, /import \{ countClaimRisks, resolveArchiveRiskTone \} from '..\/utils\/archiveCenterListFilters\.js'/)
assert.match(documentsCenterView, /function buildDocumentRiskMeta\(row\) \{[\s\S]*countClaimRisks\(riskFlags, riskSummary\)/)
assert.match(documentsCenterView, /function buildDocumentRiskMeta\(row\) \{[\s\S]*countClaimRisks\(riskFlags, riskSummary, viewerOptions\)/)
assert.match(documentsCenterView, /riskTone: riskMeta\.tone,[\s\S]*riskLabel: riskMeta\.label,[\s\S]*riskCount: riskMeta\.count,[\s\S]*riskTags: riskMeta\.tags/)
assert.match(documentsCenterView, /function matchesRiskLevelTab\(row, tab\) \{[\s\S]*tab === '高风险'[\s\S]*row\.riskTone === 'high'/)
assert.match(documentListSharedStyles, /\.risk-level-tags\s*\{[\s\S]*display:\s*inline-flex;/)

View File

@@ -1543,7 +1543,7 @@ test('application preview merges rule center travel estimate into highlighted ro
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})
test('application preview blocks policy estimate when transport mode is missing', () => {
test('application preview calculates base policy estimate when transport mode is missing', () => {
const currentUser = { name: '李文静', grade: 'P5', location: '武汉' }
const preview = buildLocalApplicationPreview(
'我要申请2月20日-23日去上海出差辅助国网仿生产项目部署',
@@ -1551,9 +1551,15 @@ test('application preview blocks policy estimate when transport mode is missing'
{ today: '2026-06-09' }
)
const request = buildApplicationPolicyEstimateRequest(preview, currentUser)
assert.equal(request.canCalculate, false)
assert.equal(request.reason, '缺少出行方式')
assert.equal(request.payload, null)
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, {
days: 4,
location: '上海',
grade: 'P5',
transport_mode: null,
origin_location: '武汉',
travel_date: '2026-02-20'
})
assert.equal(preview.missingFields.includes('出行方式'), true)
assert.equal(preview.readyToSubmit, false)
@@ -1586,14 +1592,17 @@ test('application preview blocks policy estimate when transport mode is missing'
assert.equal(blockedEstimatePreview.fields.transportMode, '')
assert.equal(blockedEstimatePreview.fields.transportEstimatedAmount, '')
assert.equal(blockedEstimatePreview.fields.policyEstimate, '填写地点和天数后自动测算')
assert.equal(blockedEstimatePreview.fields.lodgingDailyCap, '250元/天')
assert.equal(blockedEstimatePreview.fields.subsidyDailyCap, '100元/天')
assert.equal(blockedEstimatePreview.fields.policyEstimate, '交通待补充 + 住宿 1,000元 + 补贴 400元 = 1,400元4天不含交通')
assert.equal(blockedEstimatePreview.fields.amount, '1,400元不含交通')
assert.equal(blockedEstimatePreview.missingFields.includes('出行方式'), true)
assert.equal(staleEstimatePreview.fields.reason, '辅助国网仿生产项目部署')
assert.equal(staleEstimatePreview.fields.transportMode, '火车')
assert.equal(staleEstimatePreview.missingFields.includes('出行方式'), false)
assert.equal(staleEstimatePreview.fields.transportPolicy, '当前尚未接通实时票务价格查询 API无法获取当前实际票价先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用最终报销以实际票据金额为准')
assert.match(staleEstimatePreview.fields.policyEstimate, /交通 720元/)
assert.equal(staleEstimatePreview.fields.amount, '2,120元')
assert.equal(staleEstimatePreview.fields.transportMode, '')
assert.equal(staleEstimatePreview.missingFields.includes('出行方式'), true)
assert.equal(staleEstimatePreview.fields.transportPolicy, '选择火车、飞机或轮船后自动预估交通费用')
assert.equal(staleEstimatePreview.fields.policyEstimate, '交通待补充 + 住宿 1,000元 + 补贴 400元 = 1,400元4天不含交通')
assert.equal(staleEstimatePreview.fields.amount, '1,400元不含交通')
})
test('application preview editor refreshes transport estimate after mode change', async () => {

View File

@@ -190,3 +190,87 @@ test('legacy risk text falls back to semantic visibility defaults', () => {
assert.equal(resolveRiskActionability(legacyFlag, { businessStage: 'reimbursement' }), 'fixable_by_submitter')
assert.equal(resolveRiskVisibilityScope(legacyFlag, { businessStage: 'reimbursement' }), 'submitter')
})
test('application submitter can see fixable policy/trip risks in detail', () => {
// 申请单申请人在详情页可见可自行整改的风险(信息完整性、差旅、金额),
// 以便申请时知晓风险及原因并补充修正。
const cards = [
{
id: 'application-fields-missing',
businessStage: 'expense_application',
tone: 'low',
risk: '差旅申请基础信息不完整,请补充地点、事由、起止时间和预计金额。',
risk_domain: 'policy',
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
},
{
id: 'budget-detail',
businessStage: 'expense_application',
tone: 'high',
risk: '预算可用余额不足。',
risk_domain: 'budget',
visibility_scope: 'budget_manager',
actionability: 'budget_governance'
},
{
id: 'profile-detail',
businessStage: 'expense_application',
tone: 'medium',
risk: '历史差旅画像异常。',
risk_domain: 'profile',
visibility_scope: 'leader',
actionability: 'review_decision'
}
]
const visibleCards = filterRiskCardsForVisibility(cards, { request: applicationRequest, currentUser: submitter })
// 申请人只可见 fixable_by_submitter 的信息完整性类风险,
// budget 走预算审批人、profile 走领导,申请人均不可见。
assert.deepEqual(visibleCards.map((card) => card.id), ['application-fields-missing'])
})
test('application leader can see review_decision risks that submitter cannot', () => {
// 审批人可见 review_decision 类风险(画像、审批流程等),
// 满足诉求2提交后领导能看到风险点。
const cards = [
{
id: 'profile-detail',
businessStage: 'expense_application',
tone: 'medium',
risk: '历史差旅画像异常。',
risk_domain: 'profile',
visibility_scope: 'leader',
actionability: 'review_decision'
},
{
id: 'application-fields-missing',
businessStage: 'expense_application',
tone: 'low',
risk: '差旅申请基础信息不完整。',
risk_domain: 'policy',
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
}
]
const visibleCards = filterRiskCardsForVisibility(cards, {
request: applicationRequest,
currentUser: { id: 'EMP-P7', name: '直属领导' },
canViewApprovalRiskAdvice: true
})
assert.deepEqual(visibleCards.map((card) => card.id), ['profile-detail', 'application-fields-missing'])
})
test('application fixable risks derive submitter semantics without hardcoded leader fallback', () => {
// 验证申请单阶段 policy/trip/amount 域不再被硬编码为 leader/review_decision
// 而是沿用与报销单一致的 fixable_by_submitter 语义。
const policyFlag = {
source: 'submission_review',
severity: 'low',
message: '差旅申请基础信息不完整。',
business_stage: 'expense_application'
}
assert.equal(resolveRiskActionability(policyFlag, { businessStage: 'expense_application' }), 'fixable_by_submitter')
assert.equal(resolveRiskVisibilityScope(policyFlag, { businessStage: 'expense_application' }), 'submitter')
})

View File

@@ -645,8 +645,8 @@ test('AI advice template renders grouped section titles with completion before r
assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
assert.match(detailViewScript, /return '风险提示'/)
assert.match(detailViewScript, /isCurrentApplicant\.value && hasVisibleRiskCards\.value/)
assert.match(detailViewScript, /isApplicationDocument\.value \? '申请风险提示' : '风险提示'/)
assert.match(detailViewScript, /return isEditableRequest\.value \? 'AI建议' : '风险提示'/)
assert.doesNotMatch(detailViewScript, /return '报销风险提示'/)
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)

View File

@@ -48,7 +48,13 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
assert.match(aiMode, /!candidates\.length/)
assert.match(aiMode, /ai_application_start_inline/)
assert.match(aiMode, /buildRequiredApplicationMissingText/)
assert.match(aiMode, /function startAiApplicationDraft/)
assert.match(aiMode, /function startAiApplicationPreview/)
assert.match(aiMode, /buildLocalApplicationPreview/)
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
assert.match(aiMode, /applicationPreview:\s*preview/)
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
})
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
@@ -72,9 +78,28 @@ test('AI mode attaches required application lookup result before steward plannin
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
})
test('AI mode automatically continues required application gate decisions from steward plan', () => {
assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan\)/)
assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*startAiApplicationDraft\('travel', '差旅费'/)
assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/)
assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan\)/)
test('AI mode handles document query prompts locally before steward planning', () => {
assert.match(aiMode, /resolveAiDocumentQueryIntent\(prompt/)
assert.match(aiMode, /async function handleAiDocumentQueryIntent/)
assert.match(aiMode, /buildAiDocumentQueryConditionSummary/)
assert.match(aiMode, /filterAiDocumentQueryRecords\(payload, intent\)/)
assert.match(aiMode, /fetchApprovalExpenseClaims/)
assert.match(aiMode, /buildAiDocumentQueryMessage/)
assert.match(aiMode, /AI_DOCUMENT_QUERY_STEP_DELAY_MS/)
assert.match(aiMode, /async function updateAiDocumentQueryThinking/)
assert.match(aiMode, /解析自然语言筛选条件/)
assert.match(aiMode, /查询业务单据接口/)
assert.match(aiMode, /组合筛选单据/)
assert.match(aiMode, /if \(await handleAiDocumentQueryIntent\(prompt, pendingMessage\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
})
test('AI mode continues required application gate decisions into table preview from steward plan', () => {
assert.match(aiMode, /function continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt = ''\)/)
assert.match(aiMode, /flow\.flowId === 'travel_application'[\s\S]*void startAiApplicationPreview\('travel', '差旅费', prompt/)
assert.match(aiMode, /flow\.flowId === 'travel_reimbursement'[\s\S]*startAiExpenseDraft\('travel', '差旅费', true/)
assert.match(aiMode, /continueAiRequiredApplicationGateFromPlan\(normalizedPlan, prompt\)/)
assert.match(aiMode, /class="workbench-ai-application-preview application-preview-shell"/)
assert.match(aiMode, /resolveInlineApplicationPreviewRows\(message\)/)
assert.match(aiMode, /commitInlineApplicationPreviewEditor\(message\)/)
})

View File

@@ -194,6 +194,7 @@ test('personal workbench view swaps the traditional dashboard with the AI mode s
assert.match(workbenchView, /:sidebar-command="aiSidebarCommand"/)
assert.match(workbenchView, /@conversation-change="emit\('ai-conversation-change', \$event\)"/)
assert.match(workbenchView, /@conversation-history-change="emit\('ai-conversation-history-change', \$event\)"/)
assert.match(workbenchView, /@open-document="emit\('open-document', \$event\)"/)
assert.match(workbenchView, /<PersonalWorkbench[\s\S]*v-else[\s\S]*key="traditional"/)
assert.match(workbenchView, /workbenchMode:\s*\{[\s\S]*type:\s*String,[\s\S]*default:\s*'traditional'/)
assert.match(workbenchView, /aiSidebarCommand:\s*\{[\s\S]*type:\s*Object/)
@@ -233,7 +234,7 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiMode, /class="workbench-ai-thread"[\s\S]*@scroll\.passive="handleInlineConversationScroll"/)
assert.match(aiMode, /workbench-ai-answer-card/)
assert.match(aiMode, /workbench-ai-answer-markdown/)
assert.match(aiMode, /v-html="renderInlineMarkdown\(message\.content\)"/)
assert.match(aiMode, /v-html="renderInlineConversationHtml\(message\.content\)"/)
assert.match(aiMode, /workbench-ai-message-actions/)
assert.match(aiMode, /workbench-ai-conversation-actions/)
assert.match(aiMode, /scrollInlineConversationToTop/)
@@ -257,17 +258,28 @@ test('AI mode screen follows the approved reference structure', () => {
assert.doesNotMatch(aiMode, /思考过程/)
assert.doesNotMatch(aiMode, /message\.pending \?/)
assert.match(aiMode, /placeholder="继续和小财管家对话\.\.\."/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__head\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__body\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__foot\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__amount\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-document-card__meta\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-action-link\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-table-wrap\)/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-image-frame\)/)
assert.match(aiMode, /import \{ fetchSettings \} from '\.\.\/\.\.\/services\/settings\.js'/)
assert.match(aiMode, /import \{ fetchStewardPlan, fetchStewardPlanStream \} from '\.\.\/\.\.\/services\/steward\.js'/)
assert.match(aiMode, /import \{ useWorkbenchComposerDate \} from '\.\.\/\.\.\/composables\/useWorkbenchComposerDate\.js'/)
assert.match(aiMode, /loadAiWorkbenchConversationHistory/)
assert.match(aiMode, /saveAiWorkbenchConversation/)
assert.match(aiMode, /deleteAiWorkbenchConversation/)
assert.match(aiMode, /import \{ renderMarkdown \} from '\.\.\/\.\.\/utils\/markdown\.js'/)
assert.match(aiMode, /import \{ renderAiConversationHtml \} from '\.\.\/\.\.\/utils\/aiConversationHtmlRenderer\.js'/)
assert.match(aiMode, /function renderInlineConversationHtml\(content\) \{[\s\S]*return renderAiConversationHtml\(content\)[\s\S]*\}/)
assert.doesNotMatch(aiMode, /import \{ renderMarkdown \} from '\.\.\/\.\.\/utils\/markdown\.js'/)
assert.match(aiMode, /buildStewardPlanRequest/)
assert.match(aiMode, /buildStewardPlanMessageText/)
assert.match(aiMode, /buildStewardSuggestedActions/)
assert.match(aiMode, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change'\]\)/)
assert.match(aiMode, /const emit = defineEmits\(\['conversation-change', 'conversation-history-change', 'open-document'\]\)/)
assert.match(aiMode, /function startInlineConversation\(prompt, entry = \{\}, files = \[\]\)/)
assert.match(aiMode, /activateInlineConversation\(\{[\s\S]*title:[\s\S]*\}\)[\s\S]*conversationMessages\.value\.push\(createInlineMessage\('user'/)
assert.match(aiMode, /persistCurrentConversation\(\)/)
@@ -355,6 +367,9 @@ test('AI mode screen follows the approved reference structure', () => {
assert.match(aiModeStyles, /\.workbench-ai-answer-card\s*\{[\s\S]*box-shadow:\s*none;[\s\S]*backdrop-filter:\s*none;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown\s*\{[\s\S]*line-height:\s*1\.86;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(h3\)\s*\{[\s\S]*font-size:\s*21px;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-focus-grid\)\s*\{[\s\S]*border-left:\s*3px solid/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-focus-card\)\s*\{[\s\S]*background:\s*transparent;/)
assert.match(aiModeStyles, /\.workbench-ai-answer-markdown :deep\(\.ai-html-step-index\)\s*\{[\s\S]*background:\s*transparent;[\s\S]*font-size:\s*17px;/)
assert.match(aiModeStyles, /\.workbench-ai-date-popover\s*\{[\s\S]*animation:\s*workbenchAiPopoverIn/)
assert.match(aiModeStyles, /\.workbench-ai-send-btn:not\(:disabled\)\s*\{[\s\S]*linear-gradient\(135deg,[\s\S]*#1d4ed8/)
assert.match(aiModeStyles, /\.workbench-ai-composer--inline\s*\{[\s\S]*min-height:\s*126px;[\s\S]*box-shadow:\s*none;/)