import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' import { ref } from 'vue' import { applyApplicationBusinessTimeContext, buildApplicationPreviewFooterMessage, buildApplicationPreviewRows, buildApplicationPreviewSubmitText, buildApplicationTemplatePreview, applyApplicationPolicyEstimateResult, buildApplicationPolicyEstimateRequest, buildLocalApplicationPreview, buildLocalApplicationPreviewMessage, buildModelRefinedApplicationPreview, applicationDateRangesOverlap, normalizeApplicationPreview, normalizeTransportModeOption, resolveApplicationDateRange, resolveApplicationTimeLabel, shouldRequireApplicationModelReview, shouldUseLocalApplicationPreview } from '../src/utils/expenseApplicationPreview.js' import { buildMockApplicationTransportEstimate, resolveMockApplicationTransportWaitMs, buildSystemApplicationEstimate } from '../src/utils/expenseApplicationEstimate.js' import { TRAVEL_PLANNING_ACTION_GENERATE, TRAVEL_PLANNING_ACTION_SKIP, buildTravelPlanningNudgeMessage, buildTravelPlanningRecommendation, buildTravelPlanningSuggestedActions } from '../src/utils/travelApplicationPlanning.js' import { renderMarkdown } from '../src/utils/markdown.js' import { createMessage as createConversationMessage, hasMeaningfulSessionMessages } from '../src/views/scripts/travelReimbursementConversationModel.js' import { buildStewardSuggestedActions, filterStewardBlockingMissingFields } from '../src/views/scripts/stewardPlanModel.js' import { buildStewardFieldCompletionContinuation, buildStewardFieldCompletionRawText } from '../src/views/scripts/stewardFieldCompletionModel.js' import { shouldUseBudgetCompileReport } from '../src/views/scripts/budgetAssistantReportModel.js' import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js' import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js' import { ASSISTANT_SCOPE_ACTION_SWITCH, ASSISTANT_SCOPE_SESSION_APPLICATION, ASSISTANT_SCOPE_SESSION_EXPENSE, ASSISTANT_SCOPE_SESSION_STEWARD, buildUnsupportedBusinessScopeConversation, inferAssistantScopeTarget, resolveAssistantScopeGuard } from '../src/utils/assistantSessionScope.js' import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js' import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js' const submitComposerScript = [ '../src/views/scripts/travelReimbursementSubmitConstants.js', '../src/views/scripts/travelReimbursementSubmitApplicationConflicts.js', '../src/views/scripts/travelReimbursementSubmitApplicationPreview.js', '../src/views/scripts/travelReimbursementSubmitLocalPreviewFlow.js', '../src/views/scripts/travelReimbursementSubmitStewardDelegation.js', '../src/views/scripts/travelReimbursementSubmitAttachmentFlow.js', '../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', '../src/views/scripts/travelReimbursementSubmitRecognitionFlow.js', '../src/views/scripts/travelReimbursementSubmitResponseModel.js', '../src/views/scripts/useTravelReimbursementSubmitComposer.js' ].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n') const stewardServiceScript = readFileSync( fileURLToPath(new URL('../src/services/steward.js', import.meta.url)), 'utf8' ) 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' ) const stewardFieldCompletionScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/stewardFieldCompletionModel.js', import.meta.url)), 'utf8' ) const createViewTemplate = readFileSync( fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)), 'utf8' ) const messageItemTemplate = readFileSync( fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)), 'utf8' ) const messageItemStyles = readFileSync( fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-item.css', import.meta.url)), 'utf8' ) const applicationMessageStyles = readFileSync( fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-application.css', import.meta.url)), 'utf8' ) const conversationModelScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)), 'utf8' ) const previewEditorScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)), 'utf8' ) const flowScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)), 'utf8' ) const personalWorkbenchAiModeScript = readFileSync( fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)), 'utf8' ) const applicationPreviewFlowScript = readFileSync( fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)), 'utf8' ) function createFlowHarness() { return useTravelReimbursementFlow({ activeSessionType: ref('application'), reviewDrawerMode: ref(''), insightPanelCollapsed: ref(true), isKnowledgeSession: ref(false), fetchAgentRunDetail: async () => null, buildLocalIntentPreview: () => '本地意图预览', buildLocalExtractionProgressMessages: () => ['正在抽取信息'], summarizeSemanticIntentDetail: () => '模型已完成意图识别', summarizeSemanticParseDetail: () => '模型已完成信息抽取', SCENARIO_LABELS: {}, INTENT_LABELS: {}, EXPENSE_TYPE_LABELS: {}, FLOW_STEP_FALLBACKS: { intent: { title: '意图识别', tool: 'SemanticRouter', runningText: '正在识别业务意图...', completedText: '已识别业务意图' }, extraction: { title: '信息抽取', tool: 'SemanticExtractor', runningText: '正在抽取关键信息...', completedText: '已抽取关键信息' }, 'application-submit-success': { title: '申请单提交成功', tool: 'ApplicationSubmit', runningText: '正在提交申请单...', completedText: '申请单提交成功' } }, REVIEW_DRAWER_MODE_FLOW: 'flow', REVIEW_DRAWER_MODE_REVIEW: 'review', FLOW_STEP_STATUS_PENDING: 'pending', FLOW_STEP_STATUS_RUNNING: 'running', FLOW_STEP_STATUS_COMPLETED: 'completed', FLOW_STEP_STATUS_FAILED: 'failed' }) } test('application intent uses local preview instead of immediate orchestrator call', () => { const prompt = '申请 2026-05-20 至 2026-05-23 去上海支撑上海电力部署项目,出差4天,高铁,预计金额2358元' assert.equal( shouldUseLocalApplicationPreview(prompt, { sessionType: 'application', attachmentCount: 0, reviewAction: '', systemGenerated: false }), true ) assert.equal( shouldUseLocalApplicationPreview('帮我查询申请状态', { sessionType: 'application', attachmentCount: 0, reviewAction: '', systemGenerated: false }), 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: '李文静', 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, '4天') 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/) assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/) }) test('AI workbench routes compact travel direct-submit planner into application preview auto submit', () => { assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan/) assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan/) assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan/) assert.match( personalWorkbenchAiModeScript, /async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/ ) assert.match( personalWorkbenchAiModeScript, /modelPlan = await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files\)/ ) assert.match( personalWorkbenchAiModeScript, /const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/ ) assert.match( personalWorkbenchAiModeScript, /applicationFlow\.startAiApplicationPreview\([\s\S]*travelApplicationRequest\.expenseType[\s\S]*travelApplicationRequest\.expenseTypeLabel[\s\S]*travelApplicationRequest\.sourceText[\s\S]*ontologyFields:\s*travelApplicationRequest\.ontologyFields[\s\S]*autoSubmit:\s*travelApplicationRequest\.autoSubmit/ ) assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/) assert.match(applicationPreviewFlowScript, /if \(options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit\)/) assert.match(applicationPreviewFlowScript, /confirmed:\s*true/) assert.match(applicationPreviewFlowScript, /skipUserMessage:\s*true/) }) test('unsupported business guidance opens in assistant conversation form', () => { const conversation = buildUnsupportedBusinessScopeConversation('你好') assert.equal(conversation.state_json.session_type, ASSISTANT_SCOPE_SESSION_STEWARD) assert.equal(conversation.messages.length, 1) assert.equal(conversation.messages[0].role, 'assistant') assert.match(conversation.messages[0].content, /小财管家暂时不处理「你好」/) assert.equal(conversation.messages[0].assistantName, '小财管家') assert.match(conversation.messages[0].content, /### 当前可继续的场景/) assert.equal( conversation.messages[0].message_json.orchestrator_payload.result.suggested_actions.length, 4 ) }) test('assistant scope guard blocks unsupported non-financial intent', () => { const greetingGuard = resolveAssistantScopeGuard('你好', ASSISTANT_SCOPE_SESSION_APPLICATION) const guard = resolveAssistantScopeGuard('帮我写一首诗,主题是春天', ASSISTANT_SCOPE_SESSION_APPLICATION) assert.equal(greetingGuard.blocked, true) assert.equal(greetingGuard.targetSessionType, '') assert.equal(greetingGuard.suggestedActions.length, 4) assert.deepEqual( greetingGuard.suggestedActions.map((item) => item.action_type), Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH) ) assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/) assert.match(greetingGuard.text, /您可以直接点下面的场景继续/) assert.equal(guard.suggestedActions.length, 4) assert.equal(guard.blocked, true) assert.equal(guard.targetSessionType, '') assert.match(guard.text, /此意图系统不支持/) assert.match(guard.text, /当前系统支持的业务范围/) }) 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: { applicationType: '差旅费用申请', time: '2026-02-20 至 2026-02-23', location: '上海市', reason: '支撑国网仿生产环境建设', days: '4天', transportMode: '火车' } }) const draftPayload = { claim_no: 'AP-202606030001-ABCDE123' } const nudge = buildTravelPlanningNudgeMessage(preview, draftPayload) const actions = buildTravelPlanningSuggestedActions(preview, draftPayload) const recommendation = buildTravelPlanningRecommendation(preview, draftPayload) assert.match(nudge, /上海市差旅申请已经提交/) assert.match(nudge, /2026-02-20 至 2026-02-23/) assert.deepEqual(actions.map((item) => item.action_type), [ TRAVEL_PLANNING_ACTION_GENERATE, TRAVEL_PLANNING_ACTION_SKIP ]) assert.match(recommendation, /轻量行程规划/) assert.match(recommendation, /优先看上午到中午抵达 上海市 的火车班次/) assert.match(recommendation, /客户现场周边/) assert.match(recommendation, /AP-202606030001-ABCDE123/) }) 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, '新疆,伊犁') assert.equal(preview.fields.reason, '服务美团业务部署') const editedPreview = normalizeApplicationPreview({ ...preview, fields: { ...preview.fields, reason: '客户现场项目支持', amount: '1900元' } }) const rows = buildApplicationPreviewRows(editedPreview) assert.deepEqual( rows.map((row) => row.label), ['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '出发时间', '返回时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用'] ) assert.match(buildApplicationPreviewSubmitText(editedPreview), /出发时间:2026-05-25/) assert.match(buildApplicationPreviewSubmitText(editedPreview), /返回时间:2026-05-28/) assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/) 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元/) }) test('application preview keeps compact direct-submit command out of business reason', () => { const preview = buildInlineApplicationPreview( '差旅费', '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交', { grade: 'P5' } ) assert.equal(preview.fields.location, '上海') assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署') assert.equal(preview.fields.transportMode, '火车') assert.equal(preview.readyToSubmit, false) assert.deepEqual(preview.missingFields, ['出发时间', '天数']) }) 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, '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,200') 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 uses selected date range and business-specific time label', () => { const preview = applyApplicationBusinessTimeContext( buildLocalApplicationPreview( '去上海出差4天,支撑国网仿生产环境部署,飞机', { name: '曹笑竹', departmentName: '技术部', position: '财务智能化产品经理', managerName: '向万红', grade: 'P5' }, { today: '2026-06-02' } ), { mode: 'range', start_date: '2026-02-20', end_date: '2026-02-23', business_time: '2026-02-20 至 2026-02-23' } ) const rows = buildApplicationPreviewRows(preview) const submitText = buildApplicationPreviewSubmitText(preview) assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '出发时间') assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23') assert.equal(preview.fields.days, '4天') assert.equal(preview.fields.reason, '支撑国网仿生产环境部署') assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间') assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20') assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间') assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23') assert.equal(rows.find((row) => row.key === 'time_return')?.editable, true) assert.match(submitText, /出发时间:2026-02-20/) assert.match(submitText, /返回时间:2026-02-23/) assert.match(submitText, /事由:支撑国网仿生产环境部署/) 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 splits compact destination and business purpose', () => { const preview = buildLocalApplicationPreview( '2026-02-20 至 2026-02-23,去上海辅助国网仿生产服务器部署,火车', { name: '曹笑竹', departmentName: '技术部', position: '财务智能化产品经理', managerName: '向万红', grade: 'P5' }, { today: '2026-06-09' } ) assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23') assert.equal(preview.fields.days, '4天') assert.equal(preview.fields.location, '上海') assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署') assert.equal(preview.fields.transportMode, '火车') assert.equal(preview.readyToSubmit, true) assert.deepEqual(preview.validationIssues, []) }) 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 normalizes model-refined location mixed with business content', () => { const rawText = '申请2月20日-23日火车出差,事由:辅助国网仿生产服务器部署' const preview = buildModelRefinedApplicationPreview( buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-09' }), { 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: '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' } ) const estimateRequest = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' }) const footer = buildApplicationPreviewFooterMessage(preview) assert.equal(preview.fields.location, '上海') assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署') assert.equal(preview.readyToSubmit, true) assert.deepEqual(preview.validationIssues, []) assert.match(footer, /#application-submit/) assert.equal(estimateRequest.canCalculate, true) assert.equal(estimateRequest.payload.location, '上海') }) 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', '地点:上海', '事由:支撑国网仿生产环境建设', '天数:4天' ].join('\n'), { name: '曹笑竹', grade: 'P5' }) const rows = buildApplicationPreviewRows(preview) assert.equal(preview.fields.applicationType, '差旅费用申请') assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23') assert.equal(preview.fields.location, '上海') assert.equal(preview.fields.reason, '支撑国网仿生产环境建设') assert.equal(preview.fields.days, '4天') assert.equal(rows.find((row) => row.key === 'reason')?.value, '支撑国网仿生产环境建设') assert.equal(rows.find((row) => row.key === 'reason')?.missing, false) assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间') assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间') }) test('application preview cleans empty time labels and keeps only business reason', () => { const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', { name: '李文静', grade: 'P5' }, { today: '2026-05-29' }) assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31') assert.equal(preview.fields.location, '九江') assert.equal(preview.fields.days, '3天') assert.equal(preview.fields.reason, '服务美团业务部署') assert.equal(preview.fields.transportMode, '火车') assert.doesNotMatch(preview.fields.reason, /发生时间|去九江|出差3天/) }) test('application preview can be refined by ontology model extraction', () => { const rawText = '发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车' const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' }, { today: '2026-05-29' }) 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: 'amount', value: '1800元', normalized_value: '1800' } ], time_range: {}, missing_slots: [] }, rawText, { name: '李文静', grade: 'P5' } ) assert.equal(refinedPreview.modelRefined, true) assert.equal(refinedPreview.parseStrategy, 'llm_primary') assert.equal(refinedPreview.modelReviewStatus, 'completed') assert.equal(refinedPreview.fields.applicationType, '差旅费用申请') assert.equal(refinedPreview.fields.time, '2026-05-29 至 2026-05-31') assert.equal(refinedPreview.fields.reason, '服务美团业务部署') assert.equal(refinedPreview.fields.transportMode, '火车') }) 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', grade: 'P5' }) const refinedPreview = buildModelRefinedApplicationPreview( localPreview, { parse_strategy: 'llm_primary', entities: [ { type: 'expense_type', value: '\u5dee\u65c5\u8d39', normalized_value: 'travel' }, { type: 'location', value: '\u4e0a\u6d77', normalized_value: '\u4e0a\u6d77' }, { type: 'reason', value: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', normalized_value: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72' }, { type: 'transport_mode', value: '\u706b\u8f66', normalized_value: '\u706b\u8f66' }, { type: 'amount', value: '1800\u5143', normalized_value: '1800' } ], time_range: { start: '2026-05-25', end: '2026-05-27' }, missing_slots: [] }, rawText, { name: '\u674e\u6587\u9759', grade: 'P5' } ) assert.equal(localPreview.fields.transportMode, '') assert.equal(refinedPreview.fields.transportMode, '') 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元', { name: '李文静', grade: 'P5' }, { today: '2026-05-29' } ) assert.equal(preview.fields.time, '2026-05-29 至 2026-05-31') assert.equal(preview.fields.days, '3天') assert.equal(preview.readyToSubmit, true) }) test('application preview keeps rule fallback distinct from model reviewed result', () => { const rawText = '申请 2026-05-20 至 2026-05-23 去上海支撑服务器部署,出差4天,火车,预计费用1800元' const localPreview = buildLocalApplicationPreview(rawText, { name: '李文静', grade: 'P5' }) const fallbackPreview = buildModelRefinedApplicationPreview( localPreview, { parse_strategy: 'rule_fallback', entities: [ { type: 'expense_type', value: '差旅费', normalized_value: 'travel' }, { type: 'location', value: '上海', normalized_value: '上海' }, { type: 'amount', value: '1800元', normalized_value: '1800' } ], time_range: { start: '2026-05-20', end: '2026-05-23' }, missing_slots: [] }, rawText, { name: '李文静', grade: 'P5' } ) const message = buildLocalApplicationPreviewMessage(fallbackPreview) const footer = buildApplicationPreviewFooterMessage(fallbackPreview) assert.equal(fallbackPreview.modelReviewStatus, 'fallback') assert.match(message, /规则兜底/) assert.match(footer, /请确认上述的信息是否填写正确/) assert.match(footer, /#application-submit/) }) test('application preview with missing budget stays in chat and asks for补充信息', () => { const preview = buildLocalApplicationPreview('我想申请去北京出差,高铁,但是不知道预算', { name: '李文静', grade: 'P5' }) assert.equal(preview.fields.amount, '待测算') assert.equal(preview.readyToSubmit, false) assert.match(buildLocalApplicationPreviewMessage(preview), /下方表格/) assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /当前还需要补充/) assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/) assert.doesNotMatch(buildLocalApplicationPreviewMessage(preview), /#application-submit/) }) test('application quick start renders a template without model review', () => { const preview = buildApplicationTemplatePreview({ name: '李文静', departmentName: '财务部', position: '财务分析师', managerName: '王强', grade: 'P5' }) const message = buildLocalApplicationPreviewMessage(preview) assert.equal(preview.modelReviewStatus, 'template') 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, /不调用大模型/) assert.match(message, /点击对应行直接填写/) assert.doesNotMatch(message, /#application-submit/) assert.match(buildApplicationPreviewFooterMessage(preview), /当前还需要补充/) }) test('application quick start template counts as deletable session content', () => { const welcomeMessage = createConversationMessage('assistant', '欢迎语', [], { isWelcome: true, welcomeQuickActions: [{ label: '快速发起申请', action: 'start_guided_application' }] }) const templateMessage = createConversationMessage('assistant', '申请模板', [], { applicationPreview: buildApplicationTemplatePreview({ name: '测试员工', departmentName: '财务部', grade: 'P5' }) }) assert.equal(hasMeaningfulSessionMessages([welcomeMessage]), false) assert.equal(hasMeaningfulSessionMessages([welcomeMessage, templateMessage]), true) }) test('application session shows intent flow, persists preview, and supports inline table edit', () => { assert.match(submitComposerScript, /shouldUseLocalApplicationPreview/) assert.match(submitComposerScript, /buildLocalApplicationPreviewMessage/) assert.match(submitComposerScript, /buildApplicationPreviewWithModelReview/) assert.match(submitComposerScript, /fetchOntologyParse/) assert.match(submitComposerScript, /calculateTravelReimbursement/) assert.match(submitComposerScript, /buildApplicationPolicyEstimateRequest/) assert.match(submitComposerScript, /模型复核中/) assert.match(submitComposerScript, /startFlowStep\('intent'/) assert.match(submitComposerScript, /startFlowStep\('application-review-preview'/) assert.match(submitComposerScript, /completeFlowStep\('intent'/) assert.match(submitComposerScript, /function resetStewardDelegatedInsightState\(\) \{[\s\S]*insightPanelCollapsed\.value = true/) assert.doesNotMatch(submitComposerScript, /void refineApplicationPreviewWithModel/) assert.match(submitComposerScript, /return null[\s\S]*const hasUnsavedReviewDraft/) assert.ok( submitComposerScript.indexOf('shouldUseLocalApplicationPreview') < submitComposerScript.indexOf('const payload = await runOrchestrator') ) assert.match(createViewScript, /const isApplicationSession = computed/) assert.match(createViewScript, /insightPanelCollapsed,/) assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/) assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/) assert.match(createViewScript, /useApplicationPreviewEditor/) 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/) assert.match(createViewScript, /hasMeaningfulSessionMessages\(messages\.value\)/) assert.match(messageItemTemplate, /class="application-preview-table"/) assert.match(messageItemTemplate, /class="application-preview-footer application-preview-footer-missing"/) assert.match(messageItemTemplate, /application-preview-missing-chip/) assert.match(messageItemTemplate, /当前还需要补充:/) assert.match(messageItemTemplate, /补齐后我再帮您提交申请。/) assert.match(messageItemTemplate, /class="application-preview-footer message-answer-content message-answer-markdown"/) assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/) assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/) assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/) assert.doesNotMatch(messageItemTemplate, /application-preview-date-chip/) assert.match(messageItemTemplate, /申请单据已生成/) assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/) assert.match(messageItemTemplate, /报销草稿已生成/) assert.match(messageItemTemplate, /报销草稿待保存/) assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/) assert.match(messageItemTemplate, /v-if="ui\.canOpenDraftDetail\(message\)"/) assert.match(messageItemTemplate, /class="reimbursement-draft-link"/) assert.match(messageItemTemplate, /查看详情/) assert.match(messageItemTemplate, /class="reimbursement-draft-pending-detail"/) assert.match(messageItemTemplate, /保存后可查看详情/) assert.match(messageActionsScript, /function canOpenDraftDetail\(message\)/) assert.match(createViewScript, /canOpenDraftDetail,/) assert.match(messageActionsScript, /保存后生成/) assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/) assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/) assert.ok( messageItemTemplate.indexOf('class="draft-preview application-draft-preview"') < messageItemTemplate.indexOf('class="message-detail-block review-message-block"') ) assert.match(messageItemTemplate, /application-draft-head/) assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/) assert.match(messageItemTemplate, /mdi mdi-file-document-edit-outline/) assert.match(messageItemTemplate, /'is-primary': item\.label === '单号'/) assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/) assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/) assert.match(messageItemTemplate, /ui\.openApplicationDraftDetail\(message\)/) assert.doesNotMatch(messageItemTemplate, / { 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.doesNotMatch(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/) 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, /基础规则交通费用预估表/) }) test('steward field completion reruns application preview instead of directly rendering table', () => { const continuation = { planId: 'steward-plan-transport-gap', currentTaskId: 'task-application-beijing', currentTask: { task_id: 'task-application-beijing', task_type: 'expense_application', summary: '明天前往北京出差3天,支撑国网仿生产部署', ontology_fields: { time_range: '2026-06-05 至 2026-06-07', location: '北京', reason: '支撑国网仿生产部署' }, missing_fields: ['transport_mode'] }, remainingTasks: [] } const preview = normalizeApplicationPreview({ fields: { applicationType: '差旅费用申请', time: '2026-06-05 至 2026-06-07', location: '北京', reason: '支撑国网仿生产部署', days: '3天', transportMode: '' } }) const nextContinuation = buildStewardFieldCompletionContinuation(continuation, 'transportMode', '火车') assert.equal(nextContinuation.currentTask.ontology_fields.transport_mode, '火车') assert.deepEqual(nextContinuation.currentTask.missing_fields, []) const carryText = buildStewardFieldCompletionRawText({ preview, fieldKey: 'transportMode', fieldLabel: '出行方式', value: '火车', continuation: nextContinuation }) assert.match(carryText, /用户已补充:出行方式:火车/) assert.match(carryText, /地点:北京/) assert.match(carryText, /天数:3天/) assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/) const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' }) assert.equal(rebuiltPreview.fields.location, '北京') assert.equal(rebuiltPreview.fields.transportMode, '火车') assert.equal(rebuiltPreview.fields.days, '3天') }) test('budget compile report does not steal steward delegated application rerun', () => { const staleBudgetContext = { budgetNo: 'BUD-2026-TECH', mode: 'edit', categoryRows: [] } const stewardApplicationText = [ '小财管家继续执行申请单字段补齐。', '用户已补充:出行方式:火车。', '地点:北京', '天数:3天', '处理要求:请先根据已补齐字段按基础规则交通费用预估表测算费用口径,完成系统预估金额测算,再生成申请单核对表。' ].join('\n') assert.equal(shouldUseBudgetCompileReport(stewardApplicationText, { sessionType: 'application', entrySource: 'workbench', budgetContext: staleBudgetContext }), false) assert.equal(shouldUseBudgetCompileReport('帮我生成 2026 年 Q3 预算编制建议', { sessionType: 'budget', entrySource: 'budget', budgetContext: staleBudgetContext }), true) assert.match(submitComposerScript, /if \(!stewardDelegated && shouldUseBudgetCompileReport/) }) test('text confirmation submits pending application preview before replanning steward task', () => { assert.match(stewardServiceScript, /fetchStewardRuntimeDecision/) assert.match(stewardServiceScript, /\/steward\/runtime-decisions/) 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, /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(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(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(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_THINKING_TYPEWRITER_CHUNK_SIZE = 5/) assert.match(stewardPlanFlowScript, /resolveStewardTypewriterNextIndex\(chars, index, STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE\)/) assert.match(submitComposerScript, /STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5/) assert.match(submitComposerScript, /resolveStewardTypewriterNextIndex\(chars, index, STEWARD_DELEGATED_THINKING_CHUNK_SIZE\)/) assert.match(stewardFollowupFlowScript, /STEWARD_FOLLOWUP_THINKING_CHUNK_SIZE = 5/) assert.match(stewardFollowupFlowScript, /resolveStewardTypewriterNextIndex\(chars, 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', () => { assert.match(createViewScript, /const hasStewardInitialAutoSubmitPayload = computed/) assert.match(createViewScript, /const showStewardInitialRecognition = computed/) assert.match(createViewScript, /!messages\.value\.length/) assert.match(createViewScript, /workbenchVisible\.value \|\| submitting\.value/) assert.match(createViewScript, /showStewardInitialRecognition/) assert.match(createViewTemplate, /v-if="showStewardInitialRecognition"/) assert.match(createViewTemplate, /class="steward-initial-recognition"/) assert.match(createViewTemplate, /小财管家正在识别意图/) }) test('steward application carry text does not leak transport examples into extraction', () => { const actions = buildStewardSuggestedActions({ plan_id: 'steward-plan-transport-gap', plan_status: 'ready', tasks: [ { task_id: 'task-application-beijing', task_type: 'expense_application', title: '北京出差申请', summary: '明天前往北京出差3天,支撑国网仿生产部署', assigned_agent: 'application_assistant', ontology_fields: { expense_type: 'travel', time_range: '2026-06-05 至 2026-06-07', location: '北京', reason: '支撑国网仿生产部署' }, missing_fields: ['transport_mode', 'amount', 'attachments', 'employee_no'] } ], confirmation_groups: [ { action_type: 'confirm_create_application', target_task_id: 'task-application-beijing' } ] }) const carryText = actions[0]?.payload?.carry_text || '' const currentTask = actions[0]?.payload?.steward_current_task || null assert.match(carryText, /费用类型:差旅/) assert.doesNotMatch(carryText, /费用类型:travel/) 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') assert.deepEqual(currentTask?.missing_fields, ['transport_mode']) assert.deepEqual( filterStewardBlockingMissingFields( ['transport_type', 'amount', 'attachments', 'employee_no', 'department_name'], 'expense_application' ), ['transport_mode'] ) assert.deepEqual( filterStewardBlockingMissingFields(['amount', 'attachments'], 'reimbursement'), ['amount', 'attachments'] ) const preview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' }) assert.equal(preview.fields.transportMode, '') assert.equal(preview.missingFields.includes('出行方式'), true) assert.match(stewardServiceScript, /fetchStewardSlotDecision/) assert.match(stewardServiceScript, /\/steward\/slot-decisions/) assert.match(submitComposerScript, /fetchStewardApplicationSlotDecision/) assert.match(submitComposerScript, /task_type:\s*'expense_application'/) assert.match(submitComposerScript, /steward_continuation:\s*continuation/) assert.match(suggestedActionsScript, /currentTask:\s*actionPayload\.steward_current_task/) }) test('steward application slot fallback ignores non-blocking application fields', () => { assert.match(submitComposerScript, /APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS/) assert.match(submitComposerScript, /'attachments'/) assert.match(submitComposerScript, /'employee_no'/) assert.match(submitComposerScript, /'amount'/) assert.match(submitComposerScript, /function formatStewardDecisionUserText/) assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.question/) assert.match(submitComposerScript, /formatStewardDecisionUserText\(decision\.rationale/) assert.match(submitComposerScript, /normalizeTransportModeOption\(value \|\| label, ''\)/) assert.match(suggestedActionsScript, /normalizeTransportModeOption\(value, ''\)/) assert.equal(normalizeTransportModeOption('高铁', ''), '火车') assert.equal(normalizeTransportModeOption('自驾', ''), '') assert.match(submitComposerScript, /function resolveBlockingApplicationMissingFieldsForSteward/) assert.match(submitComposerScript, /isBlockingApplicationOntologyField\(key\)/) assert.match(submitComposerScript, /canonicalField && !isBlockingApplicationOntologyField\(canonicalField\)/) assert.doesNotMatch(submitComposerScript, /附件\/凭证和员工编号为合规必需字段/) }) test('flow panel durations use backend timing instead of local preview delay', () => { const flow = createFlowHarness() flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') }) flow.startFlowStep('intent', '正在识别业务意图...') flow.completeFlowStep('intent', '本地预览完成', 80) flow.startFlowStep('extraction', '正在抽取关键信息...') flow.completeFlowStep('extraction', '本地抽取完成', 90) flow.startFlowStep('application-submit-success', { title: '申请单提交成功', tool: 'ApplicationSubmit', detail: '正在提交申请单...' }) flow.completeFlowStep('application-submit-success', '本地提交完成', 100) flow.mergeFlowRunDetail({ started_at: '2026-05-29T00:00:00.000Z', finished_at: '2026-05-29T00:00:05.000Z', status: 'succeeded', semantic_parse: {}, ontology_json: {}, tool_calls: [ { id: 'submit-1', run_id: 'run-1', tool_type: 'application', tool_name: 'application.submit', request_json: {}, response_json: { status: 'submitted', draft_payload: { status: 'submitted' } }, status: 'succeeded', duration_ms: 2360, created_at: '2026-05-29T00:00:04.000Z' } ] }) const durationByKey = Object.fromEntries(flow.flowSteps.value.map((step) => [step.key, step.durationMs])) assert.equal(durationByKey.intent, 1400) assert.equal(durationByKey.extraction, 2600) assert.equal(durationByKey['application-submit-success'], 2360) assert.equal(flow.flowTotalDurationText.value, '5.0s') assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: 0 }), '--') assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--') }) test('application submit confirmation flow only shows submit success step', () => { const flow = createFlowHarness() flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') }) flow.startFlowStep('application-submit-success', { title: '申请单提交成功', tool: 'ApplicationSubmit', detail: '正在提交费用申请...' }) flow.completeFlowResult({ status: 'succeeded', result: { answer: '申请单据已生成,并已进入审批流程。', draft_payload: { draft_type: 'expense_application', status: 'submitted', claim_no: 'AP-20260602010101-ABCDEFGH', approval_stage: '直属领导审批' } } }) assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success']) assert.deepEqual(flow.visibleFlowSteps.value.map((step) => step.key), ['application-submit-success']) const submitStep = flow.flowSteps.value[0] assert.equal(submitStep.status, 'completed') assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/) assert.doesNotMatch(flow.flowSteps.value.map((step) => step.key).join(','), /intent|extraction/) }) test('application duplicate confirmation flow marks submit step as blocked duplicate', () => { const flow = createFlowHarness() flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') }) flow.startFlowStep('application-submit-success', { title: '申请单提交成功', tool: 'ApplicationSubmit', detail: '正在提交费用申请...' }) flow.completeFlowResult({ status: 'succeeded', result: { answer: [ '检测到同一申请人、同一申请类型、同一出发时间已存在申请单,系统没有重复创建。', '已有申请单号:AP-20260602010101-ABCDEFGH', '当前节点:直属领导审批' ].join('\n') } }) assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success']) const submitStep = flow.flowSteps.value[0] assert.equal(submitStep.status, 'completed') assert.equal(submitStep.title, '重复申请已拦截') assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/) assert.doesNotMatch(submitStep.detail, /提交成功/) }) test('assistant markdown tables render with component-scoped table styling', () => { const rendered = renderMarkdown([ '| 项目 | 标准口径 | 天数 | 小计 |', '| --- | --- | ---: | ---: |', '| 住宿费 | 武汉 / P5 标准:330.00 元/天 | 1 | 330.00 元 |', '| 出差补贴 | 其他地区:伙食 55.00 元 + 基本 35.00 元 | 1 | 90.00 元 |' ].join('\n')) assert.match(rendered, /
/) assert.match(rendered, //) assert.match(rendered, /
{ const rendered = renderMarkdown([ '识别到您希望报销一笔“业务招待费”费用:', '', '基础信息识别结果:', '时间:2026-06-04', '事由:小财管家继续执行剩余任务,请填写报销单:客户接待费用报销。', '', '报销测算参考:', '先以用户填写金额或票据识别金额为基础,再结合费用类型、发生地点、业务事由和规则中心限额进行复核。' ].join('\n')) assert.match(rendered, /

基础信息识别结果<\/h3>/) assert.match(rendered, /
  • 时间<\/strong>:2026-06-04<\/li>/) assert.match(rendered, /
  • 事由<\/strong>:小财管家继续执行剩余任务/) assert.match(rendered, /

    报销测算参考<\/h3>/) assert.doesNotMatch(rendered, /基础信息识别结果:<\/h3>/) }) test('application date overlap blocks steward preview before duplicate application table', () => { const existingRange = resolveApplicationDateRange('2026-06-05 至 2026-06-07') const currentRange = resolveApplicationDateRange('2026-06-06 至 2026-06-08') const disjointRange = resolveApplicationDateRange('2026-06-08 至 2026-06-10') assert.equal(applicationDateRangesOverlap(currentRange, existingRange), true) assert.equal(applicationDateRangesOverlap(disjointRange, existingRange), false) assert.match(submitComposerScript, /function findOverlappingApplicationClaim\(applicationPreview, claimsPayload\)/) assert.match(submitComposerScript, /function normalizeApplicationExpenseType\(value\)/) assert.match(submitComposerScript, /currentExpenseType !== existingExpenseType/) assert.match(submitComposerScript, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/) assert.match(submitComposerScript, /buildApplicationDateConflictMessage\(applicationDateConflict\)/) assert.match(submitComposerScript, /meta: \[STEWARD_ASSISTANT_NAME, '申请日期冲突'\]/) assert.match(submitComposerScript, /applicationPreview: pauseForMissingFields \? null : applicationPreview/) 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-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', transport_mode: '火车', origin_location: null, travel_date: '2026-05-25' }) const estimatedPreview = applyApplicationPolicyEstimateResult(preview, { days: 3, location: '上海', matched_city: '上海', grade: 'P5', hotel_rate: 600, hotel_amount: 1800, total_allowance_rate: 120, allowance_amount: 360, 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, '当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《交通费用预估表》武汉-上海火车往返(二等座预估)暂估 720元用于申请阶段预算占用,最终报销以实际票据金额为准') assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/) 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 calculates base 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, 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) 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.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('出行方式'), 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 estimate infers days from completed date range', () => { const currentUser = { name: '\u674e\u6587\u9759', grade: 'P5', location: '\u6b66\u6c49' } const preview = normalizeApplicationPreview({ fields: { applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7', time: '2026-06-23 \u81f3 2026-06-25', location: '\u5317\u4eac', reason: '\u652f\u6491\u5ba2\u6237\u73b0\u573a\u5b9e\u65bd', days: '', transportMode: '', grade: 'P5' } }) const request = buildApplicationPolicyEstimateRequest(preview, currentUser) assert.equal(request.canCalculate, true) assert.deepEqual(request.payload, { days: 3, location: '\u5317\u4eac', grade: 'P5', transport_mode: null, origin_location: '\u6b66\u6c49', travel_date: '2026-06-23' }) const estimatedPreview = applyApplicationPolicyEstimateResult(preview, { days: 3, location: '\u5317\u4eac', matched_city: '\u5317\u4eac', grade: 'P5', hotel_rate: 450, hotel_amount: 1350, total_allowance_rate: 100, allowance_amount: 300, total_amount: 1650, rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219', rule_version: 'v1.0.0' }, currentUser) assert.equal(estimatedPreview.fields.days, '3\u5929') assert.equal(estimatedPreview.fields.lodgingDailyCap, '450\u5143/\u5929') assert.equal(estimatedPreview.fields.subsidyDailyCap, '100\u5143/\u5929') assert.equal(estimatedPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09') assert.match(estimatedPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/) }) 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, '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) assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。') }) test('application preview editor recalculates days and subsidy after date range change', async () => { const preview = normalizeApplicationPreview({ fields: { applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7', time: '2026-05-25', location: '\u4e0a\u6d77', reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', days: '1\u5929', transportMode: '\u706b\u8f66', amount: '', grade: 'P5', applicant: '\u674e\u6587\u9759', department: '\u6280\u672f\u90e8', position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406', managerName: '\u5411\u4e07\u7ea2' } }) const message = { id: 'application-preview-editor-date-message', applicationPreview: preview, text: '' } const requestedPayloads = [] const editor = useApplicationPreviewEditor({ persistSessionState: () => {}, toast: () => {}, currentUser: ref({ grade: 'P5' }), calculateTravelReimbursement: async (payload) => { requestedPayloads.push(payload) return { days: payload.days, location: payload.location, matched_city: payload.location, grade: payload.grade, hotel_rate: 450, hotel_amount: 1800, total_allowance_rate: 100, allowance_amount: 400, total_amount: 2200, rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219', rule_version: 'v1.0.0' } } }) editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time) editor.setApplicationPreviewDateMode('range') editor.applicationPreviewEditor.value.rangeStartDate = '2026-02-20' editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-23' const committed = await editor.commitApplicationPreviewDateEditor(message) assert.equal(committed, true) 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') assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929') assert.match(message.applicationPreview.fields.policyEstimate, /\u8865\u8d34 400\u5143/) }) test('application preview editor can edit return date from table row', async () => { const preview = normalizeApplicationPreview({ fields: { applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7', time: '2026-02-20 \u81f3 2026-02-23', location: '\u4e0a\u6d77', reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', days: '4\u5929', transportMode: '\u706b\u8f66', amount: '', grade: 'P5', applicant: '\u674e\u6587\u9759', department: '\u6280\u672f\u90e8', position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406', managerName: '\u5411\u4e07\u7ea2' } }) const message = { id: 'application-preview-editor-return-date-message', applicationPreview: preview, text: '' } const requestedPayloads = [] const editor = useApplicationPreviewEditor({ persistSessionState: () => {}, toast: () => {}, currentUser: ref({ grade: 'P5' }), calculateTravelReimbursement: async (payload) => { requestedPayloads.push(payload) return { days: payload.days, location: payload.location, matched_city: payload.location, grade: payload.grade, hotel_rate: 450, hotel_amount: 2250, total_allowance_rate: 100, allowance_amount: 500, total_amount: 2750, rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219', rule_version: 'v1.0.0' } } }) editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23') assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date') assert.equal(editor.applicationPreviewEditor.value.dateMode, 'range') assert.equal(editor.applicationPreviewEditor.value.rangeStartDate, '2026-02-20') editor.applicationPreviewEditor.value.rangeEndDate = '2026-02-24' const committed = await editor.commitApplicationPreviewDateEditor(message) assert.equal(committed, true) assert.deepEqual(requestedPayloads.at(-1), { days: 5, 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-24') assert.equal(message.applicationPreview.fields.days, '5\u5929') }) test('application preview editor can edit return date from inline table input', async () => { const preview = normalizeApplicationPreview({ fields: { applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7', time: '2026-02-20 \u81f3 2026-02-23', location: '\u4e0a\u6d77', reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', days: '4\u5929', transportMode: '\u706b\u8f66', amount: '', grade: 'P5', applicant: '\u674e\u6587\u9759', department: '\u6280\u672f\u90e8', position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406', managerName: '\u5411\u4e07\u7ea2' } }) const message = { id: 'application-preview-editor-inline-return-date-message', applicationPreview: preview, text: '' } const requestedPayloads = [] const editor = useApplicationPreviewEditor({ persistSessionState: () => {}, toast: () => {}, currentUser: ref({ grade: 'P5' }), calculateTravelReimbursement: async (payload) => { requestedPayloads.push(payload) return { days: payload.days, location: payload.location, matched_city: payload.location, grade: payload.grade, hotel_rate: 450, hotel_amount: 2250, total_allowance_rate: 100, allowance_amount: 500, total_amount: 2750, rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219', rule_version: 'v1.0.0' } } }) editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23') editor.applicationPreviewEditor.value.draftValue = '2026-02-24' const committed = await editor.commitApplicationPreviewEditor(message) assert.equal(committed, true) assert.deepEqual(requestedPayloads.at(-1), { days: 5, 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-24') assert.equal(message.applicationPreview.fields.time_return, undefined) assert.equal(message.applicationPreview.fields.days, '5\u5929') }) test('application preview editor opens date fields with native date input values', () => { const preview = normalizeApplicationPreview({ fields: { applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7', time: '2026-02-20 \u81f3 2026-02-23', location: '\u4e0a\u6d77', reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', days: '4\u5929' } }) const message = { id: 'application-preview-editor-native-date-message', applicationPreview: preview, text: '' } const editor = useApplicationPreviewEditor({ persistSessionState: () => {}, toast: () => {} }) editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time) assert.equal(editor.resolveApplicationPreviewEditorControl('time'), 'date') assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-20') assert.equal(editor.resolveApplicationPreviewEditorDateMax(message, 'time'), '2026-02-23') editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23') assert.equal(editor.resolveApplicationPreviewEditorControl('time_return'), 'date') assert.equal(editor.applicationPreviewEditor.value.draftValue, '2026-02-23') assert.equal(editor.resolveApplicationPreviewEditorDateMin(message, 'time_return'), '2026-02-20') }) test('application preview editor blocks invalid date ranges', async () => { const preview = normalizeApplicationPreview({ fields: { applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7', time: '2026-02-20 \u81f3 2026-02-23', location: '\u4e0a\u6d77', reason: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', days: '4\u5929', transportMode: '\u706b\u8f66', amount: '' } }) const message = { id: 'application-preview-editor-invalid-date-message', applicationPreview: preview, text: '' } const toastMessages = [] const editor = useApplicationPreviewEditor({ persistSessionState: () => {}, toast: (messageText) => { toastMessages.push(messageText) } }) editor.openApplicationPreviewEditor(message, 'time_return', '2026-02-23') editor.applicationPreviewEditor.value.draftValue = '2026-02-19' const returnCommitted = await editor.commitApplicationPreviewEditor(message) assert.equal(returnCommitted, false) assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23') assert.equal(message.applicationPreview.fields.days, '4\u5929') assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002') editor.openApplicationPreviewEditor(message, 'time', message.applicationPreview.fields.time) editor.applicationPreviewEditor.value.draftValue = '2026-02-24' const startCommitted = await editor.commitApplicationPreviewEditor(message) assert.equal(startCommitted, false) assert.equal(message.applicationPreview.fields.time, '2026-02-20 \u81f3 2026-02-23') assert.equal(toastMessages.at(-1), '\u51fa\u53d1\u65f6\u95f4\u4e0d\u80fd\u665a\u4e8e\u8fd4\u56de\u65f6\u95f4\uff0c\u8bf7\u91cd\u65b0\u9009\u62e9\u3002') }) test('application preview editor estimates after shorthand return date input', async () => { const preview = normalizeApplicationPreview({ fields: { applicationType: '\u5dee\u65c5\u8d39\u7528\u7533\u8bf7', time: '2026-06-23', location: '\u5317\u4eac', reason: '\u652f\u6491\u5ba2\u6237\u73b0\u573a\u5b9e\u65bd', days: '', transportMode: '', amount: '', grade: 'P5', applicant: '\u674e\u6587\u9759', department: '\u6280\u672f\u90e8', position: '\u8d22\u52a1\u667a\u80fd\u5316\u4ea7\u54c1\u7ecf\u7406', managerName: '\u5411\u4e07\u7ea2' } }) const message = { id: 'application-preview-editor-shorthand-return-date-message', applicationPreview: preview, text: '' } const requestedPayloads = [] const editor = useApplicationPreviewEditor({ persistSessionState: () => {}, toast: () => {}, currentUser: ref({ grade: 'P5', location: '\u6b66\u6c49' }), calculateTravelReimbursement: async (payload) => { requestedPayloads.push(payload) return { days: payload.days, location: payload.location, matched_city: payload.location, grade: payload.grade, hotel_rate: 450, hotel_amount: 1350, total_allowance_rate: 100, allowance_amount: 300, total_amount: 1650, rule_name: '\u516c\u53f8\u5dee\u65c5\u8d39\u62a5\u9500\u89c4\u5219', rule_version: 'v1.0.0' } } }) editor.openApplicationPreviewEditor(message, 'time_return', '\u5f85\u8865\u5145') editor.applicationPreviewEditor.value.draftValue = '6\u670825\u65e5' const committed = await editor.commitApplicationPreviewEditor(message) assert.equal(committed, true) assert.deepEqual(requestedPayloads.at(-1), { days: 3, location: '\u5317\u4eac', grade: 'P5', transport_mode: null, origin_location: '\u6b66\u6c49', travel_date: '2026-06-23' }) assert.equal(message.applicationPreview.fields.time, '2026-06-23 \u81f3 2026-06-25') assert.equal(message.applicationPreview.fields.days, '3\u5929') assert.equal(message.applicationPreview.fields.lodgingDailyCap, '450\u5143/\u5929') assert.equal(message.applicationPreview.fields.subsidyDailyCap, '100\u5143/\u5929') assert.equal(message.applicationPreview.fields.amount, '1,650\u5143\uff08\u4e0d\u542b\u4ea4\u901a\uff09') assert.match(message.applicationPreview.fields.policyEstimate, /\u4ea4\u901a\u5f85\u8865\u5145/) })