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

@@ -127,6 +127,13 @@ export function resolveApplicationAmount(ontology) {
}
}
function resolveApplicationTypedAmount(ontology, type) {
const entity = resolveEntity(ontology, type)
const rawValue = entity?.normalized_value || entity?.value || ''
const numericValue = Number(String(rawValue).replace(/[^\d.]/g, ''))
return Number.isFinite(numericValue) && numericValue > 0 ? numericValue : 0
}
export function resolveTimeRangeText(ontology) {
const range = ontology?.time_range || {}
if (range.start_date && range.end_date) {
@@ -261,7 +268,8 @@ function cleanupApplicationReasonCandidate(value, location = '') {
if (!text) return ''
text = text
.replace(/^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[:]\s*/u, '')
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
.replace(/^(?:类型|申请类型|费用类型|报销类型|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[:]\s*/u, '')
.replace(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/gu, '')
.replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '')
.replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '')
@@ -281,6 +289,7 @@ function cleanupApplicationReasonCandidate(value, location = '') {
}
if (!text) return ''
if (isInvalidApplicationReason(text)) return ''
if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return ''
if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return ''
if (/^[\u4e00-\u9fa5]{1,8}$/.test(text) && !/服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(text)) {
@@ -303,22 +312,44 @@ export function resolveApplicationReason(prompt, ontology = null) {
const reasonEntity = resolveEntity(ontology, 'reason') || resolveEntity(ontology, 'business_reason')
const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim()
if (entityReason) {
return cleanupApplicationReasonCandidate(entityReason, location) || entityReason
const cleanedEntityReason = cleanupApplicationReasonCandidate(entityReason, location)
if (cleanedEntityReason && !isInvalidApplicationReason(cleanedEntityReason)) {
return cleanedEntityReason
}
}
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
if (labeled) {
return cleanupApplicationReasonCandidate(labeled, location) || labeled
const cleanedLabeledReason = cleanupApplicationReasonCandidate(labeled, location)
if (cleanedLabeledReason && !isInvalidApplicationReason(cleanedLabeledReason)) {
return cleanedLabeledReason
}
}
const candidates = String(prompt || '')
.split(/[\n;]+/u)
.map((item) => cleanupApplicationReasonCandidate(item, location))
.filter(Boolean)
.filter((item) => item && !isSystemGeneratedApplicationReason(item) && !isInvalidApplicationReason(item))
const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item))
return businessCandidate || candidates.sort((left, right) => right.length - left.length)[0] || ''
}
function isInvalidApplicationReason(value = '') {
const compact = String(value || '').replace(/\s+/g, '')
if (!compact) return true
if (/^(?:类型|申请类型|费用类型|报销类型)[:]?/.test(compact)) return true
if (/^(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)$/.test(compact)) return true
return false
}
function isSystemGeneratedApplicationReason(value = '') {
const compact = String(value || '').replace(/\s+/g, '')
return compact.startsWith('小财管家继续执行')
|| compact.startsWith('处理要求')
|| compact.startsWith('已识别信息')
|| compact.startsWith('用户已补充')
}
function resolveApplicationTransportMode(ontology, prompt) {
const transportEntity = resolveEntity(ontology, 'transport_mode')
|| resolveEntity(ontology, 'transport')
@@ -383,6 +414,13 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
const reason = resolveApplicationReason(prompt, ontology) || '待补充'
const days = resolvePromptDays(prompt)
const transportMode = resolveApplicationTransportMode(ontology, prompt)
const transportEstimatedAmount = resolveApplicationTypedAmount(ontology, 'transport_estimated_amount')
const trainEstimatedAmount = resolveApplicationTypedAmount(ontology, 'train_estimated_amount')
const flightEstimatedAmount = resolveApplicationTypedAmount(ontology, 'flight_estimated_amount')
const hotelAmount = resolveApplicationTypedAmount(ontology, 'hotel_amount')
const allowanceAmount = resolveApplicationTypedAmount(ontology, 'allowance_amount')
const policyTotalAmount = resolveApplicationTypedAmount(ontology, 'policy_total_amount')
const reimbursementAmount = resolveApplicationTypedAmount(ontology, 'reimbursement_amount')
const fields = {
documentType: documentTypeEntity?.normalized_value || 'expense_application',
@@ -393,6 +431,13 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
amount: amount.value,
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
transportEstimatedAmount,
trainEstimatedAmount,
flightEstimatedAmount,
hotelAmount,
allowanceAmount,
policyTotalAmount,
reimbursementAmount,
timeRange,
location,
reason,