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

@@ -17,9 +17,9 @@ import {
normalizeApplicationPreview,
normalizeTransportModeOption,
resolveApplicationDateRange,
shouldRequireApplicationModelReview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
@@ -29,11 +29,11 @@ import {
handleBudgetCompileReportSubmit,
shouldUseBudgetCompileReport
} from './budgetAssistantReportModel.js'
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
const STEWARD_ASSISTANT_NAME = '小财管家'
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
@@ -44,6 +44,13 @@ const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
reason: 'reason',
amount: 'amount',
transportMode: 'transport_mode',
transportEstimatedAmount: 'transport_estimated_amount',
trainEstimatedAmount: 'train_estimated_amount',
flightEstimatedAmount: 'flight_estimated_amount',
hotelAmount: 'hotel_amount',
allowanceAmount: 'allowance_amount',
policyTotalAmount: 'policy_total_amount',
reimbursementAmount: 'reimbursement_amount',
department: 'department_name',
applicant: 'employee_name',
grade: 'employee_grade'
@@ -75,6 +82,13 @@ const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
reason: 'reason',
amount: 'amount',
transport_mode: 'transportMode',
transport_estimated_amount: 'transportEstimatedAmount',
train_estimated_amount: 'trainEstimatedAmount',
flight_estimated_amount: 'flightEstimatedAmount',
hotel_amount: 'hotelAmount',
allowance_amount: 'allowanceAmount',
policy_total_amount: 'policyTotalAmount',
reimbursement_amount: 'reimbursementAmount',
department_name: 'department',
employee_name: 'applicant',
employee_grade: 'grade'
@@ -87,6 +101,13 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
reason: '事由',
amount: '金额',
transport_mode: '出行方式',
transport_estimated_amount: '交通费用预估',
train_estimated_amount: '火车费用预估',
flight_estimated_amount: '飞机费用预估',
hotel_amount: '住宿测算金额',
allowance_amount: '出差补贴金额',
policy_total_amount: '规则测算合计',
reimbursement_amount: '实际报销金额',
attachments: '附件/凭证',
customer_name: '客户或项目对象',
merchant_name: '商户/开票方',
@@ -97,6 +118,13 @@ const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
'amount',
'transport_estimated_amount',
'train_estimated_amount',
'flight_estimated_amount',
'hotel_amount',
'allowance_amount',
'policy_total_amount',
'reimbursement_amount',
'attachments',
'employee_no',
'department_name',
@@ -600,24 +628,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
if (!missingFields.length) {
return fallbackText
}
if (missingFields.includes('出行方式')) {
return [
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
'',
'**原因是:还缺少“出行方式”。**',
'',
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
'',
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
].join('\n')
}
return [
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
'',
@@ -710,13 +725,10 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
]
if (missingInfo) {
const transportMissing = /出行方式/.test(missingInfo)
events.push({
eventId: `${eventPrefix}-gap`,
title: '判断待补充信息',
content: transportMissing
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
content: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
})
} else {
events.push({
@@ -809,13 +821,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
const chars = Array.from(text)
for (let index = 0; index < chars.length;) {
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
index = resolveStewardTypewriterNextIndex(chars, index)
message.text = chars.slice(0, index).join('')
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
nextTick(scrollToBottom)
}
nextTick(scrollToBottom)
}
Object.assign(message, finalExtras, {
@@ -839,13 +849,39 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
function isApplicationDraftPayload(draftPayload) {
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
}
function isSubmittedApplicationDraftPayload(draftPayload) {
return (
String(draftPayload?.draft_type || '').trim() === 'expense_application'
isApplicationDraftPayload(draftPayload)
&& String(draftPayload?.status || '').trim() === 'submitted'
)
}
function shouldExposeReviewPayloadForMessage(payload, options = {}) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
if (options.isApplicationSubmitOperation || isApplicationDraftPayload(result.draft_payload)) {
return false
}
return true
}
function buildPresentationPayload(payload, { exposeReviewPayload = true } = {}) {
if (exposeReviewPayload) {
return payload
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
return {
...payload,
result: {
...result,
review_payload: null
}
}
}
function buildOperationFeedbackState(context) {
if (!context) {
return null
@@ -1190,12 +1226,6 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return preview
}
try {
const fields = preview?.fields || {}
await waitForMockApplicationTransportQuote({
transportMode: fields.transportMode,
location: fields.location,
time: fields.time
})
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
@@ -1204,7 +1234,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
}
}
if (options.skipModelReview) {
const requireModelReview = shouldRequireApplicationModelReview(rawText)
if (options.skipModelReview && !requireModelReview) {
return {
applicationPreview: await enrichWithPolicyEstimate({
...localPreview,
@@ -2042,24 +2073,31 @@ export function useTravelReimbursementSubmitComposer(ctx) {
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
})
: null
const assistantMessage = createMessage('assistant', resolveAssistantResultText(payload, fallbackAnswer), [], {
meta: buildMessageMeta(payload, effectiveFileNames),
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
? payload.result.suggested_actions
: [],
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
draftPayload: payload?.result?.draft_payload || null,
reviewPayload: payload?.result?.review_payload || null,
const exposeReviewPayload = shouldExposeReviewPayloadForMessage(payload, { isApplicationSubmitOperation })
const presentationPayload = buildPresentationPayload(payload, { exposeReviewPayload })
const presentationResult = presentationPayload?.result && typeof presentationPayload.result === 'object'
? presentationPayload.result
: {}
const resultReviewPayload = presentationResult.review_payload || null
const resultSuggestedActions = exposeReviewPayload && Array.isArray(presentationResult.suggested_actions)
? presentationResult.suggested_actions
: []
const assistantMessage = createMessage('assistant', resolveAssistantResultText(presentationPayload, fallbackAnswer), [], {
meta: buildMessageMeta(presentationPayload, effectiveFileNames),
citations: Array.isArray(presentationResult.citations) ? presentationResult.citations : [],
suggestedActions: resultSuggestedActions,
queryPayload: normalizeExpenseQueryPayload(presentationResult.query_payload),
draftPayload: presentationResult.draft_payload || null,
reviewPayload: resultReviewPayload,
reviewPanelScope: stewardDelegated
? ''
: resolveReviewPanelScope({
reviewPayload: payload?.result?.review_payload || null,
reviewPayload: resultReviewPayload,
reviewAction: reviewActionResult,
fileCount: files.length,
rawText
}),
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : [],
riskFlags: Array.isArray(presentationResult.risk_flags) ? presentationResult.risk_flags : [],
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
stewardContinuation: options.stewardContinuation || null
@@ -2084,7 +2122,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} else {
replaceMessage(pendingMessage.id, assistantMessage)
const nextInsight = buildAgentInsight(
payload,
presentationPayload,
effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews)
)