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:
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user