refactor(travel): split reimbursement create workflow

完整修改内容:

- 拆分 TravelReimbursementCreateView:提取审核面板纯模型、消息操作、建议动作处理、生命周期 watcher/UI 映射、小财管家运行时、续办流程和运行时文本模型,减少主组件继续堆叠业务分支。
- 调整申请预览链路:新增本地申请意图 gate,完善复杂差旅申请的大模型复核判断、交通方式缺失/候选识别、规则中心交通费用预估合并和申请冲突处理。
- 优化小财管家流程:抽出 steward typewriter 分段策略,避免 Markdown 表格逐字闪烁;补齐跨助手 carry、字段补齐续办、文本确认提交和行程规划推荐动作。
- 调整消息与样式:移除申请预览日期 chip 样式,收敛申请卡片/报销草稿消息的展示与复制、朗读、反馈入口逻辑。
- 更新测试:将源码锚点迁移到新模块,覆盖申请预览、提交确认、小财管家续办、引导流和审核抽屉相关断言。

验证:

- node --check web/src/views/scripts/TravelReimbursementCreateView.js 及新增拆分模块
- npm --prefix web run build
- node --test web/tests/expense-application-fast-preview.test.mjs web/tests/expense-application-submit-rich-confirm.test.mjs web/tests/travel-reimbursement-guided-flow.test.mjs

说明:

- 后端/规则/容器配置/Audit 页面等工作区已有改动未纳入本提交。
- 容器内后端定向 pytest 曾运行 timeout 180s /tmp/x-financial-server-venv/bin/pytest -q <相关后端测试>,180 秒超时且超时前已有失败标记,未作为通过项记录。
- TravelReimbursementCreateView 当前仍超过 800 行,后续仍需继续拆分;本提交先把新增职责模块控制在 800 行内,阻止主类/主模块继续膨胀。
This commit is contained in:
Codex
2026-06-13 14:52:26 +00:00
parent 336fee9d93
commit 8b952c9a26
28 changed files with 4510 additions and 2730 deletions

View File

@@ -20,6 +20,7 @@ import {
normalizeTransportModeOption,
resolveApplicationDateRange,
resolveApplicationTimeLabel,
shouldRequireApplicationModelReview,
shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js'
import {
@@ -50,6 +51,14 @@ import {
import {
shouldUseBudgetCompileReport
} from '../src/views/scripts/budgetAssistantReportModel.js'
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
import {
ASSISTANT_SCOPE_SESSION_APPLICATION,
ASSISTANT_SCOPE_SESSION_EXPENSE,
ASSISTANT_SCOPE_SESSION_STEWARD,
inferAssistantScopeTarget,
resolveAssistantScopeGuard
} from '../src/utils/assistantSessionScope.js'
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
@@ -65,6 +74,26 @@ const createViewScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
'utf8'
)
const messageActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementMessageActions.js', import.meta.url)),
'utf8'
)
const suggestedActionsScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
'utf8'
)
const stewardRuntimeScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
'utf8'
)
const stewardRuntimeTextModelScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardRuntimeTextModel.js', import.meta.url)),
'utf8'
)
const stewardFollowupFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardFollowupFlow.js', import.meta.url)),
'utf8'
)
const stewardPlanFlowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useStewardPlanFlow.js', import.meta.url)),
'utf8'
@@ -131,7 +160,7 @@ function createFlowHarness() {
}
test('application intent uses local preview instead of immediate orchestrator call', () => {
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差3高铁预计金额2358元'
const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差4高铁预计金额2358元'
assert.equal(
shouldUseLocalApplicationPreview(prompt, {
sessionType: 'application',
@@ -150,6 +179,33 @@ test('application intent uses local preview instead of immediate orchestrator ca
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('小财管家\n23:04\n这是费用申请核对结果请核对', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('我要申请', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
false
)
assert.equal(
shouldUseLocalApplicationPreview('去上海出差,支撑国网仿生产环境部署', {
sessionType: 'application',
attachmentCount: 0,
reviewAction: '',
systemGenerated: false
}),
true
)
const preview = buildLocalApplicationPreview(prompt, {
name: '李文静',
@@ -161,7 +217,7 @@ test('application intent uses local preview instead of immediate orchestrator ca
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.days, '4天')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.fields.amount, '2358元')
assert.equal(preview.fields.applicant, '李文静')
@@ -175,6 +231,33 @@ test('application intent uses local preview instead of immediate orchestrator ca
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('assistant scope guard blocks unsupported non-financial intent', () => {
const guard = resolveAssistantScopeGuard('帮我写一首诗,主题是春天', ASSISTANT_SCOPE_SESSION_APPLICATION)
assert.equal(guard.blocked, true)
assert.equal(guard.targetSessionType, '')
assert.match(guard.text, /此意图系统不支持/)
assert.match(guard.text, /当前系统支持的业务范围/)
assert.deepEqual(guard.suggestedActions, [])
})
test('assistant scope guard routes related business intent instead of blocking', () => {
const guard = resolveAssistantScopeGuard('帮我查一下报销单状态', ASSISTANT_SCOPE_SESSION_APPLICATION)
assert.equal(guard.blocked, undefined)
assert.equal(guard.targetSessionType, ASSISTANT_SCOPE_SESSION_EXPENSE)
assert.match(guard.text, /报销助手/)
assert.equal(guard.suggestedActions[0].payload.session_type, ASSISTANT_SCOPE_SESSION_EXPENSE)
})
test('assistant scope guard keeps current supported application intent and steward finance queries', () => {
assert.equal(
resolveAssistantScopeGuard('申请下周去上海出差,支撑服务器部署', ASSISTANT_SCOPE_SESSION_APPLICATION),
null
)
assert.equal(inferAssistantScopeTarget('查询一下预算余额'), ASSISTANT_SCOPE_SESSION_STEWARD)
})
test('travel application submit can continue with conversational planning recommendation', () => {
const preview = normalizeApplicationPreview({
fields: {
@@ -273,12 +356,12 @@ test('application estimate builds deterministic mock transport amount and total'
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.equal(datedTrainEstimate.source, 'fallback_transport_budget_estimate_v1')
assert.equal(datedTrainEstimate.basisText, '预估交通费用 1,100元')
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
assert.equal(flightEstimate.amountDisplay, '3,600')
assert.equal(flightEstimate.amountDisplay, '3,200')
assert.equal(shipEstimate.amountDisplay, '1,040')
assert.equal(totalEstimate.transportAmountDisplay, '1,040')
assert.equal(totalEstimate.totalAmountDisplay, '3,200')
@@ -323,6 +406,168 @@ test('application preview uses selected date range and business-specific time la
assert.doesNotMatch(submitText, /发生时间:/)
})
test('application preview parses same-month shorthand date range', () => {
const preview = buildLocalApplicationPreview(
'我要申请2月20日-23日去上海出差辅助国网仿生产项目部署',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const rows = buildApplicationPreviewRows(preview)
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产项目部署')
assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/)
})
test('application preview blocks submit when date range conflicts with explicit days', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差3天辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const normalized = normalizeApplicationPreview(preview)
const footer = buildApplicationPreviewFooterMessage(normalized)
assert.equal(normalized.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(normalized.fields.days, '3天')
assert.equal(normalized.readyToSubmit, false)
assert.equal(normalized.validationIssues[0].code, 'time_days_conflict')
assert.match(footer, /按自然日为 4 天/)
assert.match(footer, /填写的是 3 天/)
})
test('application preview blocks submit when location candidates conflict', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去北京出差4天地点上海辅助国网仿生产服务器部署火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
const footer = buildApplicationPreviewFooterMessage(preview)
assert.equal(preview.readyToSubmit, false)
assert.equal(preview.validationIssues[0].code, 'location_candidates_conflict')
assert.match(footer, /同时出现多个地点/)
assert.match(footer, /北京/)
assert.match(footer, /上海/)
})
test('application preview does not treat application type labels as locations', () => {
const preview = normalizeApplicationPreview({
sourceText: [
'费用申请出差',
'任务摘要:交通方式和出差预算待补充',
'申请类型:差旅费用申请',
'地点:上海',
'申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
].join('\n'),
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '服务国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '2120元',
grade: 'P5',
applicant: '曹笑竹',
department: '技术部',
position: '产品经理',
managerName: '向万红'
}
})
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
assert.doesNotMatch(buildApplicationPreviewFooterMessage(preview), /多个地点|费用申请/)
})
test('application preview trusts model-refined fields over noisy source candidates', () => {
const preview = normalizeApplicationPreview({
sourceText: [
'任务摘要:交通方式和出差预算待补充',
'申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
].join('\n'),
modelRefined: true,
modelReviewStatus: 'completed',
parseStrategy: 'llm_primary',
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '服务国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '2120元',
grade: 'P5',
applicant: '曹笑竹',
department: '技术部',
position: '产品经理',
managerName: '向万红'
}
})
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
test('application preview blocks submit when transport candidates conflict', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差4天辅助国网仿生产服务器部署出行方式飞机坐火车',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.readyToSubmit, false)
assert.equal(preview.validationIssues[0].code, 'transport_candidates_conflict')
assert.match(buildApplicationPreviewFooterMessage(preview), /同时出现多个出行方式/)
})
test('application preview normalizes compact amount candidates', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差4天辅助国网仿生产服务器部署火车预计费用1.8k',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-09' }
)
assert.equal(preview.fields.amount, '1800元')
assert.equal(preview.readyToSubmit, true)
assert.deepEqual(preview.validationIssues, [])
})
test('application preview keeps labeled reason in structured travel form', () => {
const preview = buildLocalApplicationPreview([
'发生时间2026-02-20 至 2026-02-23',
@@ -392,7 +637,80 @@ test('application preview can be refined by ontology model extraction', () => {
assert.equal(refinedPreview.fields.transportMode, '火车')
})
test('application preview ignores model-only transport mode guesses', () => {
test('application preview preserves ontology amount roles for travel estimates', () => {
const rawText = '申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'
const localPreview = buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-13' })
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '上海', normalized_value: '上海' },
{ type: 'reason', value: '服务国网仿生产服务器部署', normalized_value: '服务国网仿生产服务器部署' },
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
{ type: 'transport_estimated_amount', value: '720元', normalized_value: '720' },
{ type: 'hotel_amount', value: '1000元', normalized_value: '1000' },
{ type: 'allowance_amount', value: '400元', normalized_value: '400' },
{ type: 'policy_total_amount', value: '2120元', normalized_value: '2120' }
],
time_range: {
start_date: '2026-02-20',
end_date: '2026-02-23'
},
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
assert.equal(refinedPreview.fields.amount, '2120元')
assert.equal(refinedPreview.fields.transportEstimatedAmount, '720元')
assert.equal(refinedPreview.fields.hotelAmount, '1000元')
assert.equal(refinedPreview.fields.allowanceAmount, '400元')
assert.equal(refinedPreview.fields.policyTotalAmount, '2120元')
})
test('application preview ignores model reason polluted by application type', () => {
const rawText = '我申请2月20日至23日去上海出差辅助国网方法生产服务器上线部署'
const localPreview = buildLocalApplicationPreview(rawText, {
name: '曹笑竹',
grade: 'P5'
}, {
today: '2026-06-13'
})
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
{ type: 'location', value: '上海', normalized_value: '上海' },
{ type: 'reason', value: '类型:差旅费用申请', normalized_value: '类型:差旅费用申请' }
],
missing_slots: []
},
rawText,
{ name: '曹笑竹', grade: 'P5' }
)
assert.equal(localPreview.fields.reason, '辅助国网方法生产服务器上线部署')
assert.equal(refinedPreview.fields.reason, '辅助国网方法生产服务器上线部署')
assert.doesNotMatch(refinedPreview.fields.reason, /类型|差旅费用申请/)
})
test('application preview strips internal steward instruction from reason', () => {
const preview = buildLocalApplicationPreview(
'申请2月20-23日去上海出差事由辅助国网仿生产服务器部署请直接生成申请单核对结果信息足够时生成申请单但在入库或提交审批前仍需让我确认',
{ name: '曹笑竹', grade: 'P5' },
{ today: '2026-06-09' }
)
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.doesNotMatch(preview.fields.reason, /请直接生成|入库|提交审批/)
})
test('application preview requires explicit transport mode before submit', () => {
const rawText = '\u7533\u8bf7 2026-05-25 \u81f3 2026-05-27 \u53bb\u4e0a\u6d77\u51fa\u5dee3\u5929\uff0c\u670d\u52a1\u9879\u76ee\u90e8\u7f72\uff0c\u9884\u8ba1\u8d39\u75281800\u5143'
const localPreview = buildLocalApplicationPreview(rawText, {
name: '\u674e\u6587\u9759',
@@ -421,10 +739,25 @@ test('application preview ignores model-only transport mode guesses', () => {
assert.equal(localPreview.fields.transportMode, '')
assert.equal(refinedPreview.fields.transportMode, '')
assert.ok(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'))
assert.equal(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'), true)
assert.equal(refinedPreview.readyToSubmit, false)
})
test('application preview does not treat transport prompt options as selected mode', () => {
const preview = buildLocalApplicationPreview(
'当前还需要补充:出行方式。请先补充出行方式,可以选择火车、飞机或轮船。',
{ name: '李文静', grade: 'P5' }
)
const mixedPreview = buildLocalApplicationPreview(
'任务摘要:交通方式和出差预算待补充\n申请2月20日-23日火车去上海出差',
{ name: '李文静', grade: 'P5' },
{ today: '2026-06-09' }
)
assert.equal(preview.fields.transportMode, '')
assert.equal(mixedPreview.fields.transportMode, '火车')
})
test('application preview precomputes a date range from today when only days are provided', () => {
const preview = buildLocalApplicationPreview(
'去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元',
@@ -438,7 +771,7 @@ test('application preview precomputes a date range from today when only days are
})
test('application preview keeps rule fallback distinct from model reviewed result', () => {
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差3火车预计费用1800元'
const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差4火车预计费用1800元'
const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' })
const fallbackPreview = buildModelRefinedApplicationPreview(
localPreview,
@@ -545,11 +878,11 @@ test('application session shows intent flow, persists preview, and supports inli
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
assert.match(createViewScript, /useApplicationPreviewEditor/)
assert.match(createViewScript, /message-bubble-application-preview/)
assert.match(createViewScript, /buildApplicationPreviewFooterMessage/)
assert.match(createViewScript, /function buildApplicationPreviewFooterText\(message\)/)
assert.match(createViewScript, /buildApplicationPreviewSubmitText/)
assert.match(createViewScript, /user_input_text: applicationSubmitText/)
assert.match(messageActionsScript, /message-bubble-application-preview/)
assert.match(messageActionsScript, /buildApplicationPreviewFooterMessage/)
assert.match(messageActionsScript, /function buildApplicationPreviewFooterText\(message\)/)
assert.match(stewardRuntimeScript, /buildApplicationPreviewSubmitText/)
assert.match(stewardRuntimeScript, /user_input_text: applicationSubmitText/)
assert.match(conversationModelScript, /applicationPreview: null/)
assert.match(conversationModelScript, /applicationPreview: message\.applicationPreview \|\| null/)
assert.match(conversationModelScript, /\|\| message\.applicationPreview/)
@@ -564,7 +897,7 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
assert.match(messageItemTemplate, /application-preview-date-chip/)
assert.doesNotMatch(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
@@ -575,9 +908,9 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /查看详情/)
assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/)
assert.match(messageItemTemplate, /保存后可查看详情/)
assert.match(createViewScript, /function canOpenDraftDetail\(message\)/)
assert.match(messageActionsScript, /function canOpenDraftDetail\(message\)/)
assert.match(createViewScript, /canOpenDraftDetail,/)
assert.match(createViewScript, /保存后生成/)
assert.match(messageActionsScript, /保存后生成/)
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
assert.ok(
@@ -619,12 +952,12 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
assert.match(createViewScript, /function shouldShowAssistantMessageActions/)
assert.match(createViewScript, /function buildMessageOperationFeedbackContext/)
assert.match(createViewScript, /function isMessageFeedbackSelected/)
assert.match(createViewScript, /function submitOperationFeedbackForMessage/)
assert.match(createViewScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
assert.match(createViewScript, /stewardContinuation:\s*stewardSubmitContinuation/)
assert.match(messageActionsScript, /function shouldShowAssistantMessageActions/)
assert.match(messageActionsScript, /function buildMessageOperationFeedbackContext/)
assert.match(messageActionsScript, /function isMessageFeedbackSelected/)
assert.match(messageActionsScript, /function submitOperationFeedbackForMessage/)
assert.match(stewardRuntimeScript, /const stewardSubmitContinuation = message\?\.stewardContinuation \|\| null/)
assert.match(stewardRuntimeScript, /stewardContinuation:\s*stewardSubmitContinuation/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('single'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-start'\)/)
assert.match(createViewTemplate, /handleComposerDateInputChange\('range-end'\)/)
@@ -675,26 +1008,26 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(flowScript, /refreshCompleted/)
})
test('steward application missing transport asks before rendering preview table', () => {
test('steward application missing transport blocks preview table', () => {
assert.match(submitComposerScript, /function shouldPauseStewardApplicationPreview/)
assert.match(submitComposerScript, /function sanitizeStewardDelegatedTaskSummary/)
assert.match(submitComposerScript, /交通方式和\(\?:预算\|预计\)\?金额待补充/)
assert.match(submitComposerScript, /出差费用预算/)
assert.match(submitComposerScript, /预估\|预计\|预算\)\?费用/)
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
assert.match(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.doesNotMatch(submitComposerScript, /缺少“出行方式”[\s\S]{0,500}更新下方核对表/)
assert.doesNotMatch(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
assert.match(createViewScript, /payload\.applicationPreview/)
assert.match(createViewScript, /function continueStewardApplicationFieldCompletion/)
assert.match(createViewScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
assert.match(createViewScript, /skipUserMessage:\s*true/)
assert.match(createViewScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
assert.match(createViewScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
assert.match(createViewScript, /commitApplicationPreviewEditor\(targetMessage\)/)
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
assert.match(suggestedActionsScript, /submitComposerInternal\(\{[\s\S]*stewardContinuation: continuation/)
assert.match(suggestedActionsScript, /skipUserMessage:\s*true/)
assert.match(suggestedActionsScript, /targetMessage\.applicationPreview = normalizeApplicationPreview\(sourcePreview\)/)
assert.match(suggestedActionsScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
assert.match(suggestedActionsScript, /commitApplicationPreviewEditor\(targetMessage\)/)
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
assert.match(stewardFieldCompletionScript, /模拟查询交通票据/)
assert.match(stewardFieldCompletionScript, /基础规则交通费用预估表/)
})
test('steward field completion reruns application preview instead of directly rendering table', () => {
@@ -739,7 +1072,7 @@ test('steward field completion reruns application preview instead of directly re
assert.match(carryText, /用户已补充:出行方式:火车/)
assert.match(carryText, /地点:北京/)
assert.match(carryText, /天数3天/)
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据/)
assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/)
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
assert.equal(rebuiltPreview.fields.location, '北京')
@@ -758,7 +1091,7 @@ test('budget compile report does not steal steward delegated application rerun',
'用户已补充:出行方式:火车。',
'地点:北京',
'天数3天',
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。'
'处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。'
].join('\n')
assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, {
@@ -777,66 +1110,109 @@ test('budget compile report does not steal steward delegated application rerun',
test('text confirmation submits pending application preview before replanning steward task', () => {
assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/)
assert.match(stewardServiceScript, /\/steward\/runtime-decisions/)
assert.match(createViewScript, /function buildStewardRuntimeState/)
assert.match(createViewScript, /function buildStewardRuntimeFastPathDecision/)
assert.match(createViewScript, /function shouldUseStewardRuntimeLlmDecision/)
assert.match(createViewScript, /function findPendingSlotSuggestedActionContextByInput/)
assert.match(createViewScript, /function shouldPlanNewStewardTasksLocally/)
assert.match(createViewScript, /function resolveStewardRuntimeTransportAlias/)
assert.match(createViewScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
assert.match(createViewScript, /actionTransportAlias === transportAlias/)
assert.match(createViewScript, /next_action:\s*'continue_next_task'/)
assert.match(createViewScript, /next_action:\s*'submit_current_application'/)
assert.match(createViewScript, /next_action:\s*'fill_current_slot'/)
assert.match(createViewScript, /next_action:\s*'plan_new_tasks'/)
assert.match(createViewScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
assert.match(createViewScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
assert.match(createViewScript, /skipApplicationModelReview:\s*true/)
assert.match(createViewScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(createViewScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(stewardRuntimeScript, /function buildStewardRuntimeState/)
assert.match(stewardRuntimeScript, /function buildStewardRuntimeFastPathDecision/)
assert.match(stewardRuntimeScript, /function shouldUseStewardRuntimeLlmDecision/)
assert.match(stewardRuntimeScript, /function findPendingSlotSuggestedActionContextByInput/)
assert.match(stewardRuntimeTextModelScript, /function shouldPlanNewStewardTasksLocally/)
assert.match(stewardRuntimeTextModelScript, /function resolveStewardRuntimeTransportAlias/)
assert.match(stewardRuntimeScript, /const actionTransportAlias = resolveStewardRuntimeTransportAlias/)
assert.match(stewardRuntimeScript, /actionTransportAlias === transportAlias/)
assert.match(stewardRuntimeScript, /next_action:\s*'continue_next_task'/)
assert.match(stewardRuntimeScript, /next_action:\s*'submit_current_application'/)
assert.match(stewardRuntimeScript, /next_action:\s*'fill_current_slot'/)
assert.match(stewardRuntimeScript, /next_action:\s*'plan_new_tasks'/)
assert.match(stewardRuntimeScript, /suppressUserEcho:\s*userMessageAlreadyAdded/)
assert.match(suggestedActionsScript, /if \(!action\?\.suppressUserEcho\) \{[\s\S]*messages\.value\.push\(createMessage\('user', userText\)\)/)
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*true/)
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(suggestedActionsScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
assert.match(submitComposerScript, /const requireModelReview = shouldRequireApplicationModelReview\(rawText\)/)
assert.match(submitComposerScript, /if \(options\.skipModelReview && !requireModelReview\) \{[\s\S]*结构化快路径/)
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
assert.match(submitComposerScript, /const pendingSuggestedActions = Array\.isArray\(finalExtras\.suggestedActions\)/)
assert.match(submitComposerScript, /message\.suggestedActions = pendingSuggestedActions[\s\S]*message\.stewardPlan = buildStewardDelegatedPlan\(continuation, \[\.\.\.typedEvents\], 'typing'\)/)
assert.match(createViewScript, /async function handleStewardRuntimeDecision/)
assert.match(createViewScript, /const runtimeState = buildStewardRuntimeState\(\)/)
assert.match(createViewScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
assert.match(createViewScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
assert.match(createViewScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
assert.match(createViewScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(createViewScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
assert.match(createViewScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
assert.match(createViewScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
assert.match(createViewScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(createViewScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
assert.match(stewardRuntimeScript, /async function handleStewardRuntimeDecision/)
assert.match(stewardRuntimeScript, /const runtimeState = buildStewardRuntimeState\(\)/)
assert.match(stewardRuntimeScript, /if \(!hasActiveStewardRuntimeDecisionContext\(runtimeState\)\) \{[\s\S]*return false/)
assert.match(stewardRuntimeScript, /function pushStewardRuntimeUserMessage\(userText = ''\)/)
assert.match(stewardRuntimeScript, /const userMessageAlreadyAdded = options\.skipUserMessage[\s\S]*pushStewardRuntimeUserMessage\(rawText\)/)
assert.match(stewardRuntimeScript, /const fastDecision = buildStewardRuntimeFastPathDecision\(rawText, runtimeState\)[\s\S]*submitStewardPlan\(\{[\s\S]*skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(fastDecision, rawText, \{ userMessageAlreadyAdded \}\)[\s\S]*if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)/)
assert.match(stewardRuntimeScript, /if \(!shouldUseStewardRuntimeLlmDecision\(rawText, runtimeState\)\)[\s\S]*fetchStewardRuntimeDecision/)
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
assert.match(stewardRuntimeScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
assert.match(stewardRuntimeScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
assert.match(createViewScript, /function isApplicationSubmitConfirmationText/)
assert.match(createViewScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
assert.match(createViewScript, /function findPendingApplicationSubmitMessage/)
assert.match(createViewScript, /normalizedPreview\.readyToSubmit/)
assert.match(createViewScript, /async function handleApplicationSubmitConfirmationText/)
assert.match(createViewScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
assert.match(stewardRuntimeTextModelScript, /function isApplicationSubmitConfirmationText/)
assert.match(stewardRuntimeTextModelScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
assert.match(stewardRuntimeScript, /function findPendingApplicationSubmitMessage/)
assert.match(stewardRuntimeScript, /normalizedPreview\.readyToSubmit/)
assert.match(stewardRuntimeScript, /async function handleApplicationSubmitConfirmationText/)
assert.match(stewardRuntimeScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
assert.match(createViewScript, /message\.applicationSubmitConfirmed = true/)
assert.match(createViewScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed = true/)
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
})
test('application submit result does not render reimbursement review followup', () => {
assert.match(submitComposerScript, /function shouldExposeReviewPayloadForMessage\(payload, options = \{\}\)/)
assert.match(submitComposerScript, /options\.isApplicationSubmitOperation \|\| isApplicationDraftPayload\(result\.draft_payload\)/)
assert.match(submitComposerScript, /function buildPresentationPayload\(payload, \{ exposeReviewPayload = true \} = \{\}\)/)
assert.match(submitComposerScript, /review_payload:\s*null/)
assert.match(submitComposerScript, /const exposeReviewPayload = shouldExposeReviewPayloadForMessage\(payload, \{ isApplicationSubmitOperation \}\)/)
assert.match(submitComposerScript, /const presentationPayload = buildPresentationPayload\(payload, \{ exposeReviewPayload \}\)/)
assert.match(submitComposerScript, /const resultReviewPayload = presentationResult\.review_payload \|\| null/)
assert.match(submitComposerScript, /suggestedActions:\s*resultSuggestedActions/)
assert.match(submitComposerScript, /reviewPayload:\s*resultReviewPayload/)
assert.match(submitComposerScript, /buildAgentInsight\(\s*presentationPayload,/)
})
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
assert.match(stewardPlanFlowScript, /STEWARD_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(stewardPlanFlowScript, /STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(total, index \+ STEWARD_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(stewardPlanFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(stewardPlanFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(submitComposerScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(submitComposerScript, /index = Math\.min\(chars\.length, index \+ STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/)
assert.match(createViewScript, /STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE = 4/)
assert.match(createViewScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_TYPEWRITER_CHUNK_SIZE\)/)
assert.match(createViewScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
assert.match(stewardFollowupFlowScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/)
assert.match(stewardFollowupFlowScript, /resolveStewardTypewriterNextIndex\(chars, index\)/)
assert.match(stewardFollowupFlowScript, /index = Math\.min\(chars\.length, index \+ STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE\)/)
})
test('steward typewriter renders markdown table blocks at once', () => {
const tableText = '这是费用申请核对结果:\n| 字段 | 值 |\n| --- | --- |\n| 地点 | 上海 |\n下一段'
const tableChars = Array.from(tableText)
const tableIndex = tableText.indexOf('| 字段')
const nextParagraphIndex = tableText.indexOf('下一段')
const normalIndex = 0
assert.equal(resolveStewardTypewriterNextIndex(tableChars, normalIndex), 3)
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex), nextParagraphIndex)
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex - 1), nextParagraphIndex)
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 2)
})
test('application preview table appears as a whole card instead of row-by-row animation', () => {
assert.doesNotMatch(
messageItemStyles,
/structured-card-reveal-enter-active\s+\.application-preview-row\s*\{[\s\S]*animation:/,
)
assert.doesNotMatch(
messageItemStyles,
/application-preview-row:nth-child\([^)]*\)\s*\{[\s\S]*animation-delay:/,
)
})
test('complex travel application sentences require model review', () => {
assert.equal(
shouldRequireApplicationModelReview('申请2月20日-23日火车去上海出差服务国网仿生产服务器部署'),
true
)
assert.equal(shouldRequireApplicationModelReview('我想发起一笔费用申请'), false)
})
test('steward initial workbench entry shows recognition state before messages arrive', () => {
@@ -883,7 +1259,9 @@ test('steward application carry text does not leak transport examples into extra
assert.match(carryText, /费用类型:差旅/)
assert.doesNotMatch(carryText, /费用类型travel/)
assert.match(carryText, /还需要补充:出行方式/)
assert.match(carryText, /请先追问上述缺失信息/)
assert.doesNotMatch(carryText, /请先追问上述缺失信息/)
assert.doesNotMatch(carryText, /请直接生成申请单核对结果/)
assert.doesNotMatch(carryText, /入库或提交审批前/)
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
assert.doesNotMatch(carryText, /预计金额|附件\/凭证|员工编号|金额/)
assert.equal(currentTask?.task_type, 'expense_application')
@@ -909,7 +1287,7 @@ test('steward application carry text does not leak transport examples into extra
assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/)
assert.match(submitComposerScript, /task_type:\s*'expense_application'/)
assert.match(submitComposerScript, /steward_continuation:\s*continuation/)
assert.match(createViewScript, /currentTask:\s*actionPayload\.steward_current_task/)
assert.match(suggestedActionsScript, /currentTask:\s*actionPayload\.steward_current_task/)
})
test('steward application slot fallback ignores non-blocking application fields', () => {
@@ -921,7 +1299,7 @@ test('steward application slot fallback ignores non-blocking application fields'
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/)
assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/)
assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/)
assert.match(createViewScript, /normalizeTransportModeOption\(value, ''\)/)
assert.match(suggestedActionsScript, /normalizeTransportModeOption\(value, ''\)/)
assert.equal(normalizeTransportModeOption('高铁', ''), '火车')
assert.equal(normalizeTransportModeOption('自驾', ''), '')
assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/)
@@ -1045,8 +1423,10 @@ test('assistant markdown tables render with component-scoped table styling', ()
assert.match(rendered, /<th/)
assert.match(rendered, /<td/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-table-wrap\) \{[\s\S]*overflow-x: auto;[\s\S]*border: 1px solid #dbe4ee;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 460px;[\s\S]*border-collapse: separate;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 560px;[\s\S]*table-layout: fixed;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*overflow-wrap: break-word;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:first-child\),[\s\S]*\.message-answer-markdown :deep\(td:first-child\) \{[\s\S]*width: 88px;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:last-child\),[\s\S]*\.message-answer-markdown :deep\(td:last-child\) \{[\s\S]*width: 112px;[\s\S]*text-align: right;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
})
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
@@ -1082,17 +1462,24 @@ test('application date overlap blocks steward preview before duplicate applicati
assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/)
assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/)
assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/)
assert.match(createViewScript, /actionType === 'open_application_detail'/)
assert.match(suggestedActionsScript, /actionType === 'open_application_detail'/)
})
test('application preview merges rule center travel estimate into highlighted rows', () => {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-28 去上海出差3天服务项目部署火车预计费用1800元', {
const preview = buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天服务项目部署火车预计费用1800元', {
name: '李文静',
grade: 'P5'
})
const request = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5' })
assert.equal(request.canCalculate, true)
assert.deepEqual(request.payload, { days: 3, location: '上海', grade: 'P5' })
assert.deepEqual(request.payload, {
days: 3,
location: '上海',
grade: 'P5',
transport_mode: '火车',
origin_location: null,
travel_date: '2026-05-25'
})
const estimatedPreview = applyApplicationPolicyEstimateResult(preview, {
days: 3,
@@ -1103,24 +1490,84 @@ test('application preview merges rule center travel estimate into highlighted ro
hotel_amount: 1800,
total_allowance_rate: 120,
allowance_amount: 360,
total_amount: 2160,
transport_mode: '火车',
transport_origin: '武汉',
transport_destination: '上海',
transport_estimated_amount: 720,
transport_estimate_basis: '武汉-上海火车往返二等座预估',
transport_estimate_source: 'basic_rule_transport_estimate',
transport_estimate_confidence: '基础规则',
total_amount: 2880,
rule_name: '公司差旅费报销规则',
rule_version: '2026版'
}, { grade: 'P5' })
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
assert.equal(estimatedPreview.fields.transportPolicy, '预估交通费用 1,100元')
assert.equal(estimatedPreview.fields.transportPolicy, '当前尚未接通实时票务价格查询 API无法获取当前实际票价先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用最终报销以实际票据金额为准')
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
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.match(estimatedPreview.fields.policyEstimate, /交通 720元/)
assert.match(estimatedPreview.fields.policyEstimate, /2,880元/)
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '720元')
assert.equal(estimatedPreview.fields.transportEstimateSource, 'basic_rule_transport_estimate')
assert.equal(estimatedPreview.fields.transportQueryLatencyMs, '')
assert.equal(estimatedPreview.fields.amount, '2,880元')
assert.equal(buildApplicationPreviewRows(estimatedPreview).find((row) => row.key === 'policyEstimate')?.highlight, true)
})
test('application preview blocks policy estimate when transport mode is missing', () => {
const currentUser = { name: '李文静', grade: 'P5', location: '武汉' }
const preview = buildLocalApplicationPreview(
'我要申请2月20日-23日去上海出差辅助国网仿生产项目部署',
currentUser,
{ 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(preview.missingFields.includes('出行方式'), true)
assert.equal(preview.readyToSubmit, false)
const staleEstimateResult = {
days: 4,
location: '上海',
matched_city: '上海',
grade: 'P5',
hotel_rate: 250,
hotel_amount: 1000,
total_allowance_rate: 100,
allowance_amount: 400,
transport_mode: '火车',
transport_origin: '武汉',
transport_destination: '上海',
transport_estimated_amount: 720,
transport_estimate_basis: '武汉-上海火车往返二等座预估',
transport_estimate_source: 'basic_rule_transport_estimate',
transport_estimate_confidence: '基础规则',
total_amount: 2120,
travel_date: '2026-02-20',
rule_name: '差旅住宿报销标准',
rule_version: 'v1.0.0'
}
const blockedEstimatePreview = applyApplicationPolicyEstimateResult(preview, {
...staleEstimateResult,
transport_mode: ''
}, currentUser)
const staleEstimatePreview = applyApplicationPolicyEstimateResult(preview, staleEstimateResult, currentUser)
assert.equal(blockedEstimatePreview.fields.transportMode, '')
assert.equal(blockedEstimatePreview.fields.transportEstimatedAmount, '')
assert.equal(blockedEstimatePreview.fields.policyEstimate, '填写地点和天数后自动测算')
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元')
})
test('application preview editor refreshes transport estimate after mode change', async () => {
const preview = applyApplicationPolicyEstimateResult(
buildLocalApplicationPreview('申请 2026-05-25 至 2026-05-27 去上海出差3天服务项目部署', {
@@ -1162,9 +1609,9 @@ test('application preview editor refreshes transport estimate after mode change'
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.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '1,380元')
assert.equal(message.applicationPreview.fields.amount, '3,540元')
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 1,380元')
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
assert.ok(persistCount >= 2)
@@ -1223,7 +1670,14 @@ test('application preview editor recalculates days and subsidy after date range
const committed = await editor.commitApplicationPreviewDateEditor(message)
assert.equal(committed, true)
assert.deepEqual(requestedPayloads.at(-1), { days: 4, location: '\u4e0a\u6d77', grade: 'P5' })
assert.deepEqual(requestedPayloads.at(-1), {
days: 4,
location: '\u4e0a\u6d77',
grade: 'P5',
transport_mode: '\u706b\u8f66',
origin_location: null,
travel_date: '2026-02-20'
})
assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23')
assert.equal(message.applicationPreview.fields.days, '4\u5929')
assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929')