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:
@@ -1,4 +1,7 @@
|
||||
import ontologyBusinessContract from '../../../shared/ontology_business_contract.json' with { type: 'json' }
|
||||
|
||||
export const ASSISTANT_SCOPE_ACTION_SWITCH = 'switch_assistant_session'
|
||||
export const ASSISTANT_SCOPE_ACTION_UNSUPPORTED = 'unsupported_business_intent'
|
||||
|
||||
export const ASSISTANT_SCOPE_SESSION_APPLICATION = 'application'
|
||||
export const ASSISTANT_SCOPE_SESSION_EXPENSE = 'expense'
|
||||
@@ -6,7 +9,7 @@ export const ASSISTANT_SCOPE_SESSION_APPROVAL = 'approval'
|
||||
export const ASSISTANT_SCOPE_SESSION_KNOWLEDGE = 'knowledge'
|
||||
export const ASSISTANT_SCOPE_SESSION_STEWARD = 'steward'
|
||||
|
||||
const SESSION_SCOPE_CONFIG = {
|
||||
const FALLBACK_SESSION_SCOPE_CONFIG = {
|
||||
[ASSISTANT_SCOPE_SESSION_STEWARD]: {
|
||||
label: '小财管家',
|
||||
icon: 'mdi mdi-account-tie-outline',
|
||||
@@ -34,7 +37,10 @@ const SESSION_SCOPE_CONFIG = {
|
||||
}
|
||||
}
|
||||
|
||||
const ONTOLOGY_BUSINESS_CONTRACT = ontologyBusinessContract || {}
|
||||
const SESSION_SCOPE_CONFIG = ONTOLOGY_BUSINESS_CONTRACT.sessions || FALLBACK_SESSION_SCOPE_CONFIG
|
||||
const SESSION_SCOPE_TYPES = Object.keys(SESSION_SCOPE_CONFIG)
|
||||
const BUSINESS_SIGNAL_GROUPS = ONTOLOGY_BUSINESS_CONTRACT.businessSignals || {}
|
||||
|
||||
const APPLICATION_PATTERN =
|
||||
/费用申请|发起申请|申请单|事前申请|事前审批|前置审批|出差申请|申请出差|差旅申请|申请差旅|采购申请|用款申请|预算申请|申请材料|材料清单|先申请|立项申请/
|
||||
@@ -56,6 +62,45 @@ const KNOWLEDGE_PATTERN =
|
||||
/制度|政策|标准|规则|规定|流程|口径|依据|上限|额度|补贴|住宿标准|差旅标准|报销标准|票据要求|可不可以|能不能|怎么规定|如何计算|怎么算/
|
||||
const EXPENSE_OPERATION_PATTERN = /发起报销|报销单|票据|发票|火车票|高铁票|机票|的士票|草稿|归集|上传|关联单据|继续下一步/
|
||||
const CURRENT_CLAIM_RISK_PATTERN = /这张|当前|本单|该单|单据|风险|超标|异常|重复|待补/
|
||||
const FINANCE_OPERATING_PATTERN = buildKeywordPattern([
|
||||
...(BUSINESS_SIGNAL_GROUPS.budget || []),
|
||||
...(BUSINESS_SIGNAL_GROUPS.accounts_receivable || []),
|
||||
...(BUSINESS_SIGNAL_GROUPS.accounts_payable || [])
|
||||
])
|
||||
const CONTEXTUAL_FOLLOW_UP_PATTERN = buildExactKeywordPattern(ONTOLOGY_BUSINESS_CONTRACT.contextualFollowUps || [])
|
||||
|
||||
export const SUPPORTED_BUSINESS_SCOPE_TEXT = Array.isArray(ONTOLOGY_BUSINESS_CONTRACT.supportedBusinessScopes)
|
||||
? ONTOLOGY_BUSINESS_CONTRACT.supportedBusinessScopes
|
||||
: [
|
||||
'费用申请/事前审批',
|
||||
'报销与票据识别',
|
||||
'审批审核与风险解释',
|
||||
'财务制度、报销标准和流程规则问答',
|
||||
'预算、应收、应付等财务经营查询',
|
||||
'小财管家多任务拆解和附件归集'
|
||||
]
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
function buildKeywordPattern(keywords = []) {
|
||||
const source = keywords
|
||||
.map((keyword) => String(keyword || '').trim())
|
||||
.filter(Boolean)
|
||||
.map(escapeRegExp)
|
||||
.join('|')
|
||||
return source ? new RegExp(source) : /$a/
|
||||
}
|
||||
|
||||
function buildExactKeywordPattern(keywords = []) {
|
||||
const source = keywords
|
||||
.map((keyword) => String(keyword || '').trim())
|
||||
.filter(Boolean)
|
||||
.map(escapeRegExp)
|
||||
.join('|')
|
||||
return source ? new RegExp(`^(${source})$`) : /$a/
|
||||
}
|
||||
|
||||
function normalizeSessionType(sessionType) {
|
||||
const normalized = String(sessionType || '').trim()
|
||||
@@ -131,6 +176,10 @@ export function inferAssistantScopeTarget(rawText, options = {}) {
|
||||
return ASSISTANT_SCOPE_SESSION_APPLICATION
|
||||
}
|
||||
|
||||
if (FINANCE_OPERATING_PATTERN.test(text)) {
|
||||
return ASSISTANT_SCOPE_SESSION_STEWARD
|
||||
}
|
||||
|
||||
if (knowledgeMatched && !expenseMatched && !approvalMatched && !applicationMatched) {
|
||||
return ASSISTANT_SCOPE_SESSION_KNOWLEDGE
|
||||
}
|
||||
@@ -198,10 +247,58 @@ function buildScopeBoundaryText(currentSessionType, targetSessionType) {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function shouldAllowContextualFollowUp(rawText, currentSessionType, options = {}) {
|
||||
const text = normalizeText(rawText)
|
||||
if (options.hasActiveReviewPayload && CURRENT_CLAIM_RISK_PATTERN.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (!text || !CONTEXTUAL_FOLLOW_UP_PATTERN.test(text)) {
|
||||
return false
|
||||
}
|
||||
return Boolean(
|
||||
options.hasActiveReviewPayload ||
|
||||
options.hasPendingApplicationPreview ||
|
||||
options.reviewAction ||
|
||||
currentSessionType
|
||||
)
|
||||
}
|
||||
|
||||
function buildUnsupportedBusinessScopeText() {
|
||||
const message = ONTOLOGY_BUSINESS_CONTRACT.unsupportedIntentMessage || {}
|
||||
return [
|
||||
message.title || '此意图系统不支持。',
|
||||
'',
|
||||
`当前系统支持的业务范围:${SUPPORTED_BUSINESS_SCOPE_TEXT.join('、')}。`,
|
||||
'',
|
||||
message.body || '你这条内容没有识别到相关财务业务意图,系统暂不支持处理。',
|
||||
'',
|
||||
message.retryHint || '请重新描述你的财务业务要求,例如“申请下周去上海出差”“查询我的报销单进度”或“解释差旅住宿标准”。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildUnsupportedBusinessScopeGuard() {
|
||||
return {
|
||||
targetSessionType: '',
|
||||
targetLabel: '不支持的意图',
|
||||
blocked: true,
|
||||
text: buildUnsupportedBusinessScopeText(),
|
||||
meta: ['意图不支持'],
|
||||
suggestedActions: [],
|
||||
actionType: ASSISTANT_SCOPE_ACTION_UNSUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAssistantScopeGuard(rawText, currentSessionType, options = {}) {
|
||||
const normalizedCurrent = normalizeSessionType(currentSessionType)
|
||||
const targetSessionType = inferAssistantScopeTarget(rawText, options)
|
||||
if (!targetSessionType || targetSessionType === normalizedCurrent) {
|
||||
if (!targetSessionType) {
|
||||
if (shouldAllowContextualFollowUp(rawText, normalizedCurrent, options)) {
|
||||
return null
|
||||
}
|
||||
return normalizeText(rawText) ? buildUnsupportedBusinessScopeGuard() : null
|
||||
}
|
||||
|
||||
if (targetSessionType === normalizedCurrent) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const LOCATION_BANDS = {
|
||||
|
||||
const TRANSPORT_PRICE_BASE = {
|
||||
火车: { default: 360, premium: 520, remote: 900, coastal: 520 },
|
||||
飞机: { default: 850, premium: 1100, remote: 1800, coastal: 1050 },
|
||||
飞机: { default: 600, premium: 650, remote: 1600, coastal: 700 },
|
||||
轮船: { default: 320, premium: 480, remote: 680, coastal: 520 }
|
||||
}
|
||||
|
||||
@@ -125,8 +125,8 @@ export function buildMockApplicationTransportEstimate({
|
||||
queryDate,
|
||||
priceFactor,
|
||||
simulatedLatencyMs,
|
||||
source: 'mock_ticket_price_query_v1',
|
||||
confidence: 'mock',
|
||||
source: 'fallback_transport_budget_estimate_v1',
|
||||
confidence: 'fallback',
|
||||
basisText: `预估交通费用 ${amountDisplay}元`
|
||||
}
|
||||
}
|
||||
|
||||
93
web/src/utils/expenseApplicationIntentGate.js
Normal file
93
web/src/utils/expenseApplicationIntentGate.js
Normal file
@@ -0,0 +1,93 @@
|
||||
const APPLICATION_SESSION_TYPE = 'application'
|
||||
const APPLICATION_ASSISTANT_ECHO_PATTERN = /(?:这是)?费用申请核对结果|申请核对结果|请核对上述信息|确认无误后.*提交至审批流程/
|
||||
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
|
||||
const APPLICATION_CREATE_PATTERN = /申请|发起|提交|创建|新建|事前|前置|出差|差旅|采购|会务|会议|培训/
|
||||
const APPLICATION_REASON_PATTERN = /支撑|支持|部署|上线|实施|驻场|拜访|验收|培训|协助|处理|办理|参加|服务/
|
||||
|
||||
export function evaluateLocalApplicationIntentGate(rawText, options = {}) {
|
||||
const compact = compactText(rawText)
|
||||
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) {
|
||||
return block('out_of_scope', 0.98, '不是申请会话。')
|
||||
}
|
||||
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) {
|
||||
return block('out_of_scope', 0.96, '系统生成、审核动作或附件场景不走本地申请预览。')
|
||||
}
|
||||
if (!compact) {
|
||||
return block('chitchat_or_noise', 0.95, '输入为空。')
|
||||
}
|
||||
if (APPLICATION_ASSISTANT_ECHO_PATTERN.test(compact)) {
|
||||
return block('assistant_echo', 0.96, '用户输入是助手申请核对文案回显。')
|
||||
}
|
||||
if (APPLICATION_QUERY_PATTERN.test(compact)) {
|
||||
return block('ask_question', 0.86, '用户更像是在查询或询问。')
|
||||
}
|
||||
|
||||
const fields = collectLocalApplicationIntentFields(rawText)
|
||||
const fieldCount = Object.keys(fields).length
|
||||
const hasCreateSignal = APPLICATION_CREATE_PATTERN.test(compact)
|
||||
if (hasCreateSignal && fieldCount >= 2) {
|
||||
return allow('create_application', 0.78, '申请动作和结构化申请事实同时存在。', fields)
|
||||
}
|
||||
if (hasCreateSignal && fieldCount === 1 && hasStrongBusinessSignal(compact)) {
|
||||
return allow('create_application', 0.72, '申请动作和明确业务事实同时存在。', fields)
|
||||
}
|
||||
return block(
|
||||
hasCreateSignal ? 'unknown' : 'chitchat_or_noise',
|
||||
hasCreateSignal ? 0.58 : 0.82,
|
||||
'未识别到足够的新建申请意图。',
|
||||
fields
|
||||
)
|
||||
}
|
||||
|
||||
function allow(intent, confidence, reason, fields = {}) {
|
||||
return {
|
||||
intent,
|
||||
allowed: true,
|
||||
confidence,
|
||||
reason,
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
function block(intent, confidence, reason, fields = {}) {
|
||||
return {
|
||||
intent,
|
||||
allowed: false,
|
||||
confidence,
|
||||
reason,
|
||||
fields
|
||||
}
|
||||
}
|
||||
|
||||
function collectLocalApplicationIntentFields(rawText) {
|
||||
const text = String(rawText || '')
|
||||
const compact = compactText(text)
|
||||
const fields = {}
|
||||
if (/(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|[1-9]\d?月[1-3]?\d日?)/.test(compact)) {
|
||||
fields.time = 'time_text'
|
||||
}
|
||||
if (/(?:\d+|[一二两三四五六七八九十]{1,3})天/.test(compact)) {
|
||||
fields.days = 'days_text'
|
||||
}
|
||||
if (/\d+(?:\.\d+)?\s*(?:万|千|k|K|元|块|人民币)/.test(text)) {
|
||||
fields.amount = 'amount_text'
|
||||
}
|
||||
if (/(?:飞机|机票|航班|火车|高铁|动车|轮船|船票|客轮|渡轮)/.test(compact)) {
|
||||
fields.transportMode = 'transport_text'
|
||||
}
|
||||
if (/(?:去|到|赴|前往)[\u4e00-\u9fa5]{1,24}(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|$)/.test(compact)) {
|
||||
fields.location = 'location_text'
|
||||
}
|
||||
if (APPLICATION_REASON_PATTERN.test(compact)) {
|
||||
fields.reason = 'reason_text'
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
function hasStrongBusinessSignal(compactTextValue) {
|
||||
return APPLICATION_REASON_PATTERN.test(compactTextValue) || /出差|差旅/.test(compactTextValue)
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
|
||||
import { evaluateLocalApplicationIntentGate } from './expenseApplicationIntentGate.js'
|
||||
import {
|
||||
buildMockApplicationTransportEstimate,
|
||||
formatApplicationEstimateMoney,
|
||||
parseApplicationEstimateMoney,
|
||||
buildSystemApplicationEstimate
|
||||
} from './expenseApplicationEstimate.js'
|
||||
import { getTodayDateValue } from './workbenchComposerDate.js'
|
||||
|
||||
const APPLICATION_SESSION_TYPE = 'application'
|
||||
const APPLICATION_CREATE_PATTERN = /申请|事前|前置|预算|出差|差旅|采购|会务|会议|培训|办公用品|交通|住宿/
|
||||
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
|
||||
|
||||
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||||
{ key: 'applicationType', label: '申请类型' },
|
||||
{ key: 'applicant', label: '姓名', editable: false, required: false },
|
||||
@@ -111,6 +110,29 @@ function buildEndDateFromDays(startText, daysText = '') {
|
||||
return formatIsoDate(end)
|
||||
}
|
||||
|
||||
function buildDateFromMonthDay(year, month, day) {
|
||||
const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return parseIsoDate(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function resolveShortMonthDayRange(text, options = {}) {
|
||||
const match = String(text || '').match(
|
||||
/(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?\s*(?:至|到|~|—|–|--|-)\s*(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日/u
|
||||
)
|
||||
if (!match?.groups) return ''
|
||||
|
||||
const referenceYear = Number(resolvePreviewToday(options).slice(0, 4))
|
||||
const startMonth = Number(match.groups.startMonth)
|
||||
const startDay = Number(match.groups.startDay)
|
||||
const endMonth = Number(match.groups.endMonth || match.groups.startMonth)
|
||||
const endDay = Number(match.groups.endDay)
|
||||
const startDate = buildDateFromMonthDay(referenceYear, startMonth, startDay)
|
||||
const endYear = endMonth < startMonth ? referenceYear + 1 : referenceYear
|
||||
const endDate = buildDateFromMonthDay(endYear, endMonth, endDay)
|
||||
if (!startDate || !endDate) return ''
|
||||
return startDate === endDate ? startDate : `${startDate} 至 ${endDate}`
|
||||
}
|
||||
|
||||
function resolveDaysFromDateRange(rangeText) {
|
||||
const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
|
||||
if (!match) return ''
|
||||
@@ -125,6 +147,80 @@ export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
function resolveApplicationValidationIssues(fields = {}) {
|
||||
const issues = []
|
||||
const rangeDaysText = resolveDaysFromDateRange(fields.time)
|
||||
const rangeDays = parseApplicationDaysValue(rangeDaysText)
|
||||
const explicitDays = parseApplicationDaysValue(fields.days)
|
||||
if (rangeDays && explicitDays && rangeDays !== explicitDays) {
|
||||
issues.push({
|
||||
code: 'time_days_conflict',
|
||||
field: 'days',
|
||||
message: `申请时间 ${fields.time} 按自然日为 ${rangeDays} 天,但填写的是 ${explicitDays} 天。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function shouldTrustModelApplicationFields(preview = {}) {
|
||||
const status = String(preview?.modelReviewStatus || '').trim()
|
||||
const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
|
||||
return Boolean(preview?.modelRefined)
|
||||
|| status === 'completed'
|
||||
|| strategy === 'llm_primary'
|
||||
}
|
||||
|
||||
function resolveApplicationSourceValidationIssues(sourceText = '', fields = {}, preview = {}) {
|
||||
if (shouldTrustModelApplicationFields(preview)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const issues = []
|
||||
const locationCandidates = extractApplicationLocationCandidates(sourceText)
|
||||
if (locationCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'location_candidates_conflict',
|
||||
field: 'location',
|
||||
message: `当前输入里同时出现多个地点:${locationCandidates.join('、')}。请确认本次申请地点。`
|
||||
})
|
||||
}
|
||||
|
||||
const transportCandidates = extractApplicationTransportCandidates(sourceText)
|
||||
if (transportCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'transport_candidates_conflict',
|
||||
field: 'transportMode',
|
||||
message: `当前输入里同时出现多个出行方式:${transportCandidates.join('、')}。请确认本次申请使用哪一种。`
|
||||
})
|
||||
}
|
||||
|
||||
const amountCandidates = extractApplicationAmountCandidates(sourceText)
|
||||
if (amountCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'amount_candidates_conflict',
|
||||
field: 'amount',
|
||||
message: `当前输入里同时出现多个金额:${amountCandidates.join('、')}。请确认本次申请金额。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
export function shouldRequireApplicationModelReview(rawText = '') {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = compactText(text)
|
||||
if (!compact) return false
|
||||
|
||||
const hasDateRange = /(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(?:\d{1,2}月)?\d{1,2}日?)\s*(?:至|到|~|—|–|--|-)\s*(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|\d{1,2}月?\d{1,2}日?)/u.test(text)
|
||||
const hasTravelIntent = /差旅|出差|去|到|赴|前往/.test(compact)
|
||||
const hasTransport = /高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮/.test(compact)
|
||||
const hasBusinessPurpose = /服务|支撑|支持|辅助|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(compact)
|
||||
const hasInlinePurpose = hasBusinessPurpose && !/(?:事由|申请事由|出差事由|原因|用途)\s*[::]/.test(text)
|
||||
const hasMultipleClauses = /[,,。;;\n]/.test(text) || compact.length >= 24
|
||||
const signalCount = [hasDateRange, hasTravelIntent, hasTransport, hasBusinessPurpose].filter(Boolean).length
|
||||
|
||||
return (hasInlinePurpose && signalCount >= 2) || (hasMultipleClauses && signalCount >= 3)
|
||||
}
|
||||
|
||||
export function resolveApplicationDateRange(rangeText, daysText = '') {
|
||||
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || '')
|
||||
@@ -179,14 +275,106 @@ function resolveApplicationType(text) {
|
||||
function resolveApplicationAmount(text) {
|
||||
const compact = compactText(text)
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)?/u,
|
||||
/(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)/u
|
||||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
|
||||
/(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
|
||||
])
|
||||
if (labeled) return `${labeled}元`
|
||||
const normalized = normalizeApplicationAmountText(labeled)
|
||||
if (normalized) return normalized
|
||||
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeApplicationAmountText(value) {
|
||||
const text = String(value || '').replace(/[,,]/g, '').trim()
|
||||
const match = text.match(/(?<number>\d+(?:\.\d+)?)\s*(?<unit>万|千|k|K)?/u)
|
||||
if (!match?.groups) return ''
|
||||
let amount = Number(match.groups.number)
|
||||
if (!Number.isFinite(amount) || amount <= 0) return ''
|
||||
const unit = String(match.groups.unit || '').toLowerCase()
|
||||
if (unit === '万') amount *= 10000
|
||||
if (unit === '千' || unit === 'k') amount *= 1000
|
||||
return `${Number.isInteger(amount) ? amount : Number(amount.toFixed(2))}元`
|
||||
}
|
||||
|
||||
function extractApplicationLocationCandidates(text) {
|
||||
const candidates = []
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
if (labeled) candidates.push(normalizeLocationCandidate(labeled))
|
||||
|
||||
const compact = compactText(text)
|
||||
const patterns = [
|
||||
/(?:去|到|赴|前往)(?<value>[\u4e00-\u9fa5]{1,24})/gu,
|
||||
/(?<value>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)/gu
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
for (const match of compact.matchAll(pattern)) {
|
||||
candidates.push(normalizeLocationCandidate(match.groups?.value || ''))
|
||||
}
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
.filter((item) => !isInvalidApplicationLocationCandidate(item))
|
||||
}
|
||||
|
||||
function normalizeLocationCandidate(value) {
|
||||
let cleaned = String(value || '').replace(/\s+/g, '')
|
||||
for (const marker of ['前往', '去', '到', '赴']) {
|
||||
if (cleaned.includes(marker)) {
|
||||
cleaned = cleaned.slice(cleaned.lastIndexOf(marker) + marker.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
cleaned = cleaned
|
||||
.replace(/^(?:去|到|赴|前往)/u, '')
|
||||
.replace(/(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$/u, '')
|
||||
.replace(/[::,,。;;、\s]/g, '')
|
||||
return cleaned.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
|
||||
}
|
||||
|
||||
function isInvalidApplicationLocationCandidate(value) {
|
||||
const compact = compactText(value)
|
||||
if (!compact) return true
|
||||
if (/^(?:类型|申请类型|费用类型|报销类型)$/.test(compact)) return true
|
||||
if (/^(?:费用申请|差旅费用申请|交通费用申请|住宿费用申请|会务费用申请|采购费用申请|培训费用申请|报销申请|申请)$/.test(compact)) return true
|
||||
if (/(?:类型|申请类型|费用类型|报销类型)/.test(compact)) return true
|
||||
if (/(?:交通方式|出行方式|交通工具|预算|金额|费用|票据|附件|天数|时间|事由|原因|用途)/.test(compact)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function extractApplicationTransportCandidates(text) {
|
||||
const compact = compactText(text)
|
||||
return uniqueApplicationCandidates([
|
||||
resolveApplicationTransportMode(resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])),
|
||||
/高铁|动车|火车|铁路/.test(compact) ? '火车' : '',
|
||||
/飞机|机票|航班/.test(compact) ? '飞机' : '',
|
||||
/轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : ''
|
||||
])
|
||||
}
|
||||
|
||||
function extractApplicationAmountCandidates(text) {
|
||||
const candidates = []
|
||||
const source = String(text || '')
|
||||
const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu
|
||||
for (const match of source.matchAll(labelPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
const amountPattern = /(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/gu
|
||||
for (const match of source.matchAll(amountPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
}
|
||||
|
||||
function uniqueApplicationCandidates(values) {
|
||||
return values
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((item, index, list) => list.indexOf(item) === index)
|
||||
}
|
||||
|
||||
function resolveCurrentUserGrade(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.grade
|
||||
@@ -282,12 +470,45 @@ function formatDailyPolicyMoney(value) {
|
||||
|
||||
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
|
||||
const mode = String(transportMode || '').trim()
|
||||
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
||||
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
return estimate.basisText
|
||||
}
|
||||
|
||||
function buildTransportEstimateFromPolicyResult(result = {}, fields = {}) {
|
||||
const amount = parseMoneyNumber(result?.transport_estimated_amount)
|
||||
if (!amount || amount <= 0) return null
|
||||
const amountDisplay = formatPolicyMoney(amount)
|
||||
const mode = String(result?.transport_mode || fields.transportMode || '').trim()
|
||||
const origin = String(result?.transport_origin || '').trim()
|
||||
const destination = String(result?.transport_destination || result?.matched_city || fields.location || '').trim()
|
||||
const basis = String(result?.transport_estimate_basis || '').trim()
|
||||
const ruleName = String(result?.transport_estimate_rule_name || '交通费用预估表').trim()
|
||||
const routeText = [origin, destination].filter(Boolean).join('-')
|
||||
const modeText = mode ? `${mode}往返` : '往返'
|
||||
const routeModeText = routeText ? `${routeText}${modeText}` : modeText
|
||||
const displayBasis = routeModeText && basis.startsWith(routeModeText)
|
||||
? basis.slice(routeModeText.length).trim()
|
||||
: basis
|
||||
const basisSuffix = displayBasis ? `(${displayBasis})` : ''
|
||||
return {
|
||||
mode,
|
||||
amount,
|
||||
amountDisplay,
|
||||
routeType: '往返',
|
||||
origin,
|
||||
destination,
|
||||
queryDate: String(result?.travel_date || '').trim(),
|
||||
source: String(result?.transport_estimate_source || 'basic_rule_transport_estimate').trim(),
|
||||
confidence: String(result?.transport_estimate_confidence || 'basic_rule').trim(),
|
||||
basis,
|
||||
ruleCode: String(result?.transport_estimate_rule_code || '').trim(),
|
||||
ruleName,
|
||||
ruleVersion: String(result?.transport_estimate_rule_version || '').trim(),
|
||||
basisText: `当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《${ruleName}》${routeModeText}${basisSuffix}暂估 ${amountDisplay}元用于申请阶段预算占用,最终报销以实际票据金额为准`
|
||||
}
|
||||
}
|
||||
|
||||
function ensureApplicationPolicyFields(fields = {}) {
|
||||
const nextFields = { ...fields }
|
||||
if (!String(nextFields.lodgingDailyCap || '').trim()) {
|
||||
@@ -321,6 +542,11 @@ function resolveApplicationTime(text, daysText = '', options = {}) {
|
||||
return `${normalizeDateText(range[1])} 至 ${normalizeDateText(range[2])}`
|
||||
}
|
||||
|
||||
const shortMonthDayRange = resolveShortMonthDayRange(text, options)
|
||||
if (shortMonthDayRange) {
|
||||
return shortMonthDayRange
|
||||
}
|
||||
|
||||
const single = resolveFirstMatch(text, [
|
||||
/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
|
||||
/(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||||
@@ -332,7 +558,7 @@ function resolveApplicationTime(text, daysText = '', options = {}) {
|
||||
}
|
||||
|
||||
function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
|
||||
const resolvedTime = resolveApplicationTime(text, daysText)
|
||||
const resolvedTime = resolveApplicationTime(text, daysText, options)
|
||||
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
|
||||
return resolvedTime
|
||||
}
|
||||
@@ -349,7 +575,39 @@ function resolveApplicationLocation(text) {
|
||||
])
|
||||
}
|
||||
|
||||
function looksLikeTransportPromptText(text) {
|
||||
const compact = compactText(text)
|
||||
return /(?:还需要补充|当前还缺|缺少|请先补充|请选择|可以选择|可以选|打算怎么出行|怎么出行).{0,24}(?:出行方式|交通方式|交通工具)/u.test(compact)
|
||||
|| /(?:出行方式|交通方式|交通工具).{0,24}(?:待补充|缺失|请选择|可以选择|可以选)/u.test(compact)
|
||||
}
|
||||
|
||||
function resolveApplicationTransportMode(text) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
const labeledMode = normalizeTransportModeOption(labeled, '')
|
||||
if (labeledMode && !looksLikeTransportPromptText(labeled) && !/(?:请选择|可以选择|可以选)/u.test(String(labeled || ''))) {
|
||||
return labeledMode
|
||||
}
|
||||
const fullTextLooksLikePrompt = looksLikeTransportPromptText(text)
|
||||
const segments = String(text || '')
|
||||
.split(/[\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
for (const segment of segments) {
|
||||
if (looksLikeTransportPromptText(segment)) continue
|
||||
const compactSegment = compactText(segment)
|
||||
if (
|
||||
fullTextLooksLikePrompt
|
||||
&& !/(?:申请|出差|去|到|赴|前往|服务|支撑|支持|部署|上线|实施|拜访|会议)/u.test(compactSegment)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (/高铁|动车|火车|铁路/.test(compactSegment)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compactSegment)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮/.test(compactSegment)) return '轮船'
|
||||
}
|
||||
if (fullTextLooksLikePrompt) return ''
|
||||
const compact = compactText(text)
|
||||
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compact)) return '飞机'
|
||||
@@ -360,6 +618,7 @@ function resolveApplicationTransportMode(text) {
|
||||
function stripKnownContextFromReason(value, context = {}) {
|
||||
const location = String(context.location || '').trim()
|
||||
let cleaned = String(value || '')
|
||||
.replace(/(?:请直接生成申请单核对结果|信息足够时生成申请单|但在入库或提交审批前仍需让我确认|请直接生成报销核对结果|需要创建草稿、绑定附件或提交审批前仍需让我确认)[\s\S]*$/u, '')
|
||||
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?=[,,、。;;\s]|$)/gu, '')
|
||||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—|–|--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||||
@@ -387,10 +646,20 @@ function pickBusinessReasonSegment(text) {
|
||||
const segments = String(text || '')
|
||||
.split(/[,,、。;;\n]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.filter((item) => item && !isSystemGeneratedReasonText(item))
|
||||
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
|
||||
}
|
||||
|
||||
function isSystemGeneratedReasonText(value = '') {
|
||||
const compact = compactText(value)
|
||||
return compact.startsWith('小财管家继续执行')
|
||||
|| /请直接生成申请单核对结果|信息足够时生成申请单|入库或提交审批前|请直接生成报销核对结果/.test(compact)
|
||||
|| compact.startsWith('处理要求')
|
||||
|| compact.startsWith('已识别信息')
|
||||
|| compact.startsWith('用户已补充')
|
||||
|| /^(?:类型|申请类型|费用类型|报销类型)[::]?(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)?$/.test(compact)
|
||||
}
|
||||
|
||||
function resolveApplicationReason(text, context = {}) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:事由|申请事由|出差事由|原因|用途)\s*[::]\s*(?<value>[^,。;;\n]+)/u
|
||||
@@ -401,6 +670,7 @@ function resolveApplicationReason(text, context = {}) {
|
||||
const withoutContext = stripKnownContextFromReason(cleaned, context)
|
||||
const businessSegment = pickBusinessReasonSegment(withoutContext)
|
||||
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
|
||||
if (isSystemGeneratedReasonText(withoutContext)) return ''
|
||||
return withoutContext
|
||||
}
|
||||
|
||||
@@ -447,7 +717,7 @@ function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', cur
|
||||
}
|
||||
|
||||
function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||
const numericAmount = Number(fields.amount || 0)
|
||||
const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 0)
|
||||
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
||||
return `${numericAmount}元`
|
||||
}
|
||||
@@ -461,6 +731,14 @@ function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeTypedOntologyAmount(value, fallback = '') {
|
||||
const amount = Number(value || 0)
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
return `${amount}元`
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function buildMissingFields(fields) {
|
||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
|
||||
.filter((item) => item.key !== 'applicationType' && item.required !== false)
|
||||
@@ -478,6 +756,14 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
|
||||
const transportMode = String(fields.transportMode || '').trim()
|
||||
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
|
||||
|
||||
if (/差旅|出差/.test(applicationType) && !transportMode) {
|
||||
return {
|
||||
canCalculate: false,
|
||||
reason: '缺少出行方式',
|
||||
payload: null
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldEstimate || !days || !location) {
|
||||
return {
|
||||
canCalculate: false,
|
||||
@@ -492,27 +778,88 @@ export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser
|
||||
payload: {
|
||||
days,
|
||||
location,
|
||||
grade
|
||||
grade,
|
||||
transport_mode: transportMode || null,
|
||||
origin_location: String(
|
||||
currentUser.location
|
||||
|| currentUser.officeLocation
|
||||
|| currentUser.office_location
|
||||
|| currentUser.baseCity
|
||||
|| currentUser.base_city
|
||||
|| ''
|
||||
).trim() || null,
|
||||
travel_date: resolveApplicationTripDateParts(fields).startDate || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) {
|
||||
const fields = { ...(preview?.fields || {}) }
|
||||
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
|
||||
const resultTransportMode = String(result?.transport_mode || '').trim()
|
||||
const fields = {
|
||||
...(preview?.fields || {}),
|
||||
...(!String(preview?.fields?.transportMode || '').trim() && resultTransportMode
|
||||
? { transportMode: resultTransportMode }
|
||||
: {})
|
||||
}
|
||||
const hotelRate = formatPolicyMoney(result?.hotel_rate)
|
||||
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
|
||||
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
|
||||
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
|
||||
const matchedCity = String(result?.matched_city || fields.location || '').trim()
|
||||
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim()
|
||||
const systemEstimate = buildSystemApplicationEstimate({
|
||||
if (isTravelApplicationType(fields.applicationType) && !String(fields.transportMode || '').trim()) {
|
||||
return normalizeApplicationPreview({
|
||||
...preview,
|
||||
fields: {
|
||||
...fields,
|
||||
grade,
|
||||
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
||||
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
||||
transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
|
||||
policyEstimate: APPLICATION_POLICY_PENDING_TEXT,
|
||||
matchedCity,
|
||||
ruleName: String(result?.rule_name || '').trim(),
|
||||
ruleVersion: String(result?.rule_version || '').trim(),
|
||||
hotelAmount: hotelAmount ? `${hotelAmount}元` : '',
|
||||
allowanceAmount: allowanceAmount ? `${allowanceAmount}元` : '',
|
||||
transportEstimatedAmount: '',
|
||||
transportEstimateDate: '',
|
||||
transportQueryLatencyMs: '',
|
||||
transportEstimateSource: '',
|
||||
transportEstimateConfidence: '',
|
||||
policyTotalAmount: ''
|
||||
},
|
||||
policyEstimateStatus: 'pending'
|
||||
})
|
||||
}
|
||||
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
|
||||
let systemEstimate = buildSystemApplicationEstimate({
|
||||
transportMode: fields.transportMode,
|
||||
location: matchedCity || fields.location,
|
||||
time: fields.time,
|
||||
lodgingAmount: result?.hotel_amount,
|
||||
allowanceAmount: result?.allowance_amount
|
||||
})
|
||||
const policyTransportEstimate = buildTransportEstimateFromPolicyResult(result, fields)
|
||||
if (policyTransportEstimate) {
|
||||
const lodging = parseApplicationEstimateMoney(result?.hotel_amount)
|
||||
const allowance = parseApplicationEstimateMoney(result?.allowance_amount)
|
||||
const backendTotal = parseApplicationEstimateMoney(result?.total_amount)
|
||||
const totalAmount = backendTotal > 0
|
||||
? backendTotal
|
||||
: policyTransportEstimate.amount + lodging + allowance
|
||||
systemEstimate = {
|
||||
transportEstimate: policyTransportEstimate,
|
||||
transportAmount: policyTransportEstimate.amount,
|
||||
lodgingAmount: lodging,
|
||||
allowanceAmount: allowance,
|
||||
totalAmount,
|
||||
transportAmountDisplay: policyTransportEstimate.amountDisplay,
|
||||
lodgingAmountDisplay: formatApplicationEstimateMoney(lodging),
|
||||
allowanceAmountDisplay: formatApplicationEstimateMoney(allowance),
|
||||
totalAmountDisplay: formatApplicationEstimateMoney(totalAmount)
|
||||
}
|
||||
}
|
||||
const transportEstimate = systemEstimate.transportEstimate
|
||||
const transportText = transportEstimate
|
||||
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
|
||||
@@ -621,22 +968,22 @@ export function applyApplicationPolicyEstimateError(preview = {}, error = null,
|
||||
}
|
||||
|
||||
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
|
||||
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) return false
|
||||
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) return false
|
||||
|
||||
const compact = compactText(rawText)
|
||||
if (!compact || APPLICATION_QUERY_PATTERN.test(compact)) return false
|
||||
return APPLICATION_CREATE_PATTERN.test(compact)
|
||||
return evaluateLocalApplicationIntentGate(rawText, options).allowed
|
||||
}
|
||||
|
||||
export function normalizeApplicationPreview(preview = {}) {
|
||||
const fields = ensureApplicationPolicyFields(preview?.fields || {})
|
||||
const missingFields = buildMissingFields(fields)
|
||||
const validationIssues = [
|
||||
...resolveApplicationValidationIssues(fields),
|
||||
...resolveApplicationSourceValidationIssues(preview?.sourceText, fields, preview)
|
||||
]
|
||||
return {
|
||||
...preview,
|
||||
fields,
|
||||
missingFields,
|
||||
readyToSubmit: missingFields.length === 0
|
||||
validationIssues,
|
||||
readyToSubmit: missingFields.length === 0 && validationIssues.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,6 +1035,16 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
|
||||
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
|
||||
transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
|
||||
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
|
||||
transportEstimatedAmount: normalizeTypedOntologyAmount(
|
||||
ontologyFields.transportEstimatedAmount || ontologyFields.trainEstimatedAmount || ontologyFields.flightEstimatedAmount,
|
||||
currentFields.transportEstimatedAmount
|
||||
),
|
||||
trainEstimatedAmount: normalizeTypedOntologyAmount(ontologyFields.trainEstimatedAmount, currentFields.trainEstimatedAmount),
|
||||
flightEstimatedAmount: normalizeTypedOntologyAmount(ontologyFields.flightEstimatedAmount, currentFields.flightEstimatedAmount),
|
||||
hotelAmount: normalizeTypedOntologyAmount(ontologyFields.hotelAmount, currentFields.hotelAmount),
|
||||
allowanceAmount: normalizeTypedOntologyAmount(ontologyFields.allowanceAmount, currentFields.allowanceAmount),
|
||||
policyTotalAmount: normalizeTypedOntologyAmount(ontologyFields.policyTotalAmount, currentFields.policyTotalAmount),
|
||||
reimbursementAmount: normalizeTypedOntologyAmount(ontologyFields.reimbursementAmount, currentFields.reimbursementAmount),
|
||||
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
|
||||
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
|
||||
department: resolveProvidedValue(ontologyFields.department, currentFields.department || resolveCurrentUserDepartment(currentUser)),
|
||||
@@ -827,6 +1184,10 @@ export function buildLocalApplicationPreviewMessage(preview) {
|
||||
export function buildApplicationPreviewFooterMessage(preview) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||
const validationIssues = Array.isArray(normalized.validationIssues) ? normalized.validationIssues : []
|
||||
if (validationIssues.length) {
|
||||
return `${validationIssues[0].message} 请先修正后再提交申请。`
|
||||
}
|
||||
if (missingFields.length) {
|
||||
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user