- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出 - PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示 - 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试 - 新增 AI 文档卡片背景资源
1193 lines
50 KiB
JavaScript
1193 lines
50 KiB
JavaScript
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
|
||
import { evaluateLocalApplicationIntentGate } from './expenseApplicationIntentGate.js'
|
||
import {
|
||
buildMockApplicationTransportEstimate,
|
||
formatApplicationEstimateMoney,
|
||
parseApplicationEstimateMoney,
|
||
buildSystemApplicationEstimate
|
||
} from './expenseApplicationEstimate.js'
|
||
import { getTodayDateValue } from './workbenchComposerDate.js'
|
||
|
||
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||
{ key: 'applicationType', label: '申请类型' },
|
||
{ key: 'applicant', label: '姓名', editable: false, required: false },
|
||
{ key: 'grade', label: '职级', highlight: true, editable: false, required: false },
|
||
{ key: 'department', label: '部门', editable: false, required: false },
|
||
{ key: 'position', label: '岗位', editable: false, required: false },
|
||
{ key: 'managerName', label: '直属领导', editable: false, required: false },
|
||
{ key: 'time', label: '申请时间' },
|
||
{ key: 'location', label: '地点' },
|
||
{ key: 'reason', label: '事由' },
|
||
{ key: 'days', label: '天数' },
|
||
{ key: 'transportMode', label: '出行方式' },
|
||
{ key: 'lodgingDailyCap', label: '住宿上限/天', highlight: true, editable: false, required: false },
|
||
{ key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
|
||
{ key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
|
||
{ key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
|
||
{ key: 'amount', label: '系统预估费用', highlight: true, editable: false, required: false }
|
||
]
|
||
|
||
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
||
|
||
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
||
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
|
||
|
||
export function resolveApplicationTimeLabel(applicationType = '') {
|
||
const label = String(applicationType || '').trim()
|
||
if (/差旅|出差/.test(label)) return '出发时间'
|
||
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
|
||
return '申请时间'
|
||
}
|
||
|
||
function resolveApplicationFieldLabel(item, fields = {}) {
|
||
if (item.key === 'time') {
|
||
return resolveApplicationTimeLabel(fields.applicationType)
|
||
}
|
||
return item.label
|
||
}
|
||
|
||
function isTravelApplicationType(applicationType = '') {
|
||
return /差旅|出差/.test(String(applicationType || '').trim())
|
||
}
|
||
|
||
function resolveApplicationTripDateParts(fields = {}) {
|
||
const timeText = String(fields.time || '').trim()
|
||
const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||
const startDate = normalizeDateText(matchedDates[0] || timeText)
|
||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||
? explicitEndDate
|
||
: buildEndDateFromDays(startDate, fields.days)
|
||
|
||
return {
|
||
startDate,
|
||
endDate: inferredEndDate || explicitEndDate || startDate
|
||
}
|
||
}
|
||
|
||
function compactText(value) {
|
||
return String(value || '').replace(/\s+/g, '')
|
||
}
|
||
|
||
function looksLikeStructuredTravelApplication(text) {
|
||
const source = String(text || '')
|
||
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(source)
|
||
&& /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(source)
|
||
&& /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
|
||
}
|
||
|
||
function resolveFirstMatch(text, patterns = []) {
|
||
for (const pattern of patterns) {
|
||
const match = text.match(pattern)
|
||
const value = String(match?.groups?.value || match?.[1] || '').trim()
|
||
if (value) return value.replace(/[,。;;]$/, '')
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function normalizeDateText(value) {
|
||
return String(value || '').replace(/[/.]/g, '-').replace(/\s+/g, '')
|
||
}
|
||
|
||
function parseIsoDate(value) {
|
||
const match = normalizeDateText(value).match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
|
||
if (!match) return null
|
||
const [, year, month, day] = match
|
||
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
|
||
return Number.isNaN(date.getTime()) ? null : date
|
||
}
|
||
|
||
function formatIsoDate(date) {
|
||
return date.toISOString().slice(0, 10)
|
||
}
|
||
|
||
function buildEndDateFromDays(startText, daysText = '') {
|
||
const days = parseApplicationDaysValue(daysText)
|
||
const start = parseIsoDate(startText)
|
||
if (!days || !start) return ''
|
||
const end = new Date(start.getTime())
|
||
end.setUTCDate(end.getUTCDate() + Math.max(days - 1, 0))
|
||
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 ''
|
||
const start = parseIsoDate(match[1])
|
||
const end = parseIsoDate(match[2])
|
||
if (!start || !end) return ''
|
||
const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
|
||
return diffDays >= 0 ? `${diffDays + 1}天` : ''
|
||
}
|
||
|
||
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] || '')
|
||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||
? explicitEndDate
|
||
: buildEndDateFromDays(startDate, daysText)
|
||
const endDate = inferredEndDate || explicitEndDate || startDate
|
||
const start = parseIsoDate(startDate)
|
||
const end = parseIsoDate(endDate)
|
||
if (!start || !end) {
|
||
return null
|
||
}
|
||
const orderedStart = start.getTime() <= end.getTime() ? start : end
|
||
const orderedEnd = start.getTime() <= end.getTime() ? end : start
|
||
return {
|
||
startDate: formatIsoDate(orderedStart),
|
||
endDate: formatIsoDate(orderedEnd),
|
||
startTime: orderedStart.getTime(),
|
||
endTime: orderedEnd.getTime()
|
||
}
|
||
}
|
||
|
||
export function applicationDateRangesOverlap(leftRange, rightRange) {
|
||
if (!leftRange || !rightRange) {
|
||
return false
|
||
}
|
||
return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
|
||
}
|
||
|
||
function resolvePreviewToday(options = {}) {
|
||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||
if (options.now instanceof Date && !Number.isNaN(options.now.getTime())) {
|
||
return getTodayDateValue(options.now)
|
||
}
|
||
return getTodayDateValue()
|
||
}
|
||
|
||
function resolveApplicationType(text) {
|
||
const compact = compactText(text)
|
||
if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
|
||
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
|
||
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
|
||
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
|
||
if (/会务|会议|发布会|展会/.test(compact)) return '会务费用申请'
|
||
if (/采购|办公用品|文具|设备|耗材/.test(compact)) return '采购费用申请'
|
||
if (/培训|课程|学习/.test(compact)) return '培训费用申请'
|
||
return '费用申请'
|
||
}
|
||
|
||
function resolveApplicationAmount(text) {
|
||
const compact = compactText(text)
|
||
const labeled = resolveFirstMatch(text, [
|
||
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u,
|
||
/(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
|
||
])
|
||
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
|
||
|| currentUser.employeeGrade
|
||
|| currentUser.employee_grade
|
||
|| currentUser.profileGrade
|
||
|| ''
|
||
).trim()
|
||
}
|
||
|
||
function resolveCurrentUserDepartment(currentUser = {}) {
|
||
return String(
|
||
currentUser.department
|
||
|| currentUser.departmentName
|
||
|| currentUser.department_name
|
||
|| ''
|
||
).trim()
|
||
}
|
||
|
||
function resolveCurrentUserPosition(currentUser = {}) {
|
||
return String(
|
||
currentUser.position
|
||
|| currentUser.employeePosition
|
||
|| currentUser.employee_position
|
||
|| currentUser.jobTitle
|
||
|| currentUser.job_title
|
||
|| ''
|
||
).trim()
|
||
}
|
||
|
||
function resolveCurrentUserManagerName(currentUser = {}) {
|
||
return String(
|
||
currentUser.managerName
|
||
|| currentUser.manager_name
|
||
|| currentUser.directManagerName
|
||
|| currentUser.direct_manager_name
|
||
|| currentUser.leaderName
|
||
|| currentUser.leader_name
|
||
|| ''
|
||
).trim()
|
||
}
|
||
|
||
function parseApplicationDaysValue(value) {
|
||
const match = String(value || '').match(/\d+/)
|
||
const days = match ? Number(match[0]) : parseChineseNumber(value)
|
||
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
|
||
}
|
||
|
||
function parseChineseNumber(value) {
|
||
const digits = {
|
||
一: 1,
|
||
二: 2,
|
||
两: 2,
|
||
三: 3,
|
||
四: 4,
|
||
五: 5,
|
||
六: 6,
|
||
七: 7,
|
||
八: 8,
|
||
九: 9
|
||
}
|
||
const text = String(value || '').match(/[一二两三四五六七八九十]{1,3}/)?.[0] || ''
|
||
if (!text) return 0
|
||
if (text === '十') return 10
|
||
if (text.includes('十')) {
|
||
const [left, right] = text.split('十')
|
||
const tens = left ? digits[left] || 0 : 1
|
||
const ones = right ? digits[right] || 0 : 0
|
||
return tens * 10 + ones
|
||
}
|
||
return digits[text] || 0
|
||
}
|
||
|
||
function parseMoneyNumber(value) {
|
||
const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
|
||
const amount = Number(normalized)
|
||
return Number.isFinite(amount) ? amount : null
|
||
}
|
||
|
||
function formatPolicyMoney(value) {
|
||
const amount = parseMoneyNumber(value)
|
||
if (amount === null) return String(value || '').trim()
|
||
return new Intl.NumberFormat('zh-CN', {
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
|
||
}).format(amount)
|
||
}
|
||
|
||
function formatDailyPolicyMoney(value) {
|
||
const display = formatPolicyMoney(value)
|
||
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
|
||
}
|
||
|
||
function buildTransportPolicyText(transportMode, location = '', transportEstimate = null, time = '') {
|
||
const mode = String(transportMode || '').trim()
|
||
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()) {
|
||
nextFields.lodgingDailyCap = APPLICATION_POLICY_PENDING_TEXT
|
||
}
|
||
if (!String(nextFields.subsidyDailyCap || '').trim()) {
|
||
nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
|
||
}
|
||
if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
|
||
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode, nextFields.location, null, nextFields.time)
|
||
}
|
||
if (!String(nextFields.policyEstimate || '').trim()) {
|
||
nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
|
||
}
|
||
return nextFields
|
||
}
|
||
|
||
function resolveApplicationDays(text) {
|
||
const value = resolveFirstMatch(text, [
|
||
/(?:出差|申请)?(?<value>\d+)\s*天/u,
|
||
/(?<value>\d+)\s*(?:个)?工作日/u
|
||
])
|
||
return value ? `${value}天` : ''
|
||
}
|
||
|
||
function resolveApplicationTime(text, daysText = '', options = {}) {
|
||
const range = text.match(
|
||
/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—|–|--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
|
||
)
|
||
if (range) {
|
||
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
|
||
])
|
||
if (!single) return ''
|
||
const normalized = normalizeDateText(single)
|
||
const endDate = buildEndDateFromDays(normalized, daysText)
|
||
return endDate && endDate !== normalized ? `${normalized} 至 ${endDate}` : normalized
|
||
}
|
||
|
||
function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
|
||
const resolvedTime = resolveApplicationTime(text, daysText, options)
|
||
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
|
||
return resolvedTime
|
||
}
|
||
|
||
const startDate = resolvePreviewToday(options)
|
||
const endDate = buildEndDateFromDays(startDate, daysText)
|
||
return endDate && endDate !== startDate ? `${startDate} 至 ${endDate}` : startDate
|
||
}
|
||
|
||
function resolveApplicationLocation(text) {
|
||
return resolveFirstMatch(text, [
|
||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n]+)/u,
|
||
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
|
||
])
|
||
}
|
||
|
||
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 '飞机'
|
||
if (/轮船|船票|客轮|渡轮/.test(compact)) return '轮船'
|
||
return ''
|
||
}
|
||
|
||
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, '')
|
||
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
|
||
.replace(/(?:出差|申请)?\d+\s*天/gu, '')
|
||
.replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[::]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '')
|
||
.replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '')
|
||
.replace(/[,,、。;;]+/g, ',')
|
||
.replace(/^\s*(去|到|前往)/u, '')
|
||
.replace(/^[,\s]+|[,\s]+$/g, '')
|
||
.trim()
|
||
|
||
if (location) {
|
||
const escapedLocation = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
cleaned = cleaned
|
||
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||
.replace(new RegExp(`^(?:去|到|前往)${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '')
|
||
.trim()
|
||
}
|
||
|
||
return cleaned
|
||
}
|
||
|
||
function pickBusinessReasonSegment(text) {
|
||
const segments = String(text || '')
|
||
.split(/[,,、。;;\n]+/u)
|
||
.map((item) => item.trim())
|
||
.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
|
||
])
|
||
if (labeled) return stripKnownContextFromReason(labeled, context)
|
||
const cleaned = String(text || '')
|
||
.replace(/^\s*(我想|我要|帮我)?\s*(先)?\s*(发起|提交|申请)?\s*(一笔)?\s*(费用申请|申请)\s*/u, '')
|
||
const withoutContext = stripKnownContextFromReason(cleaned, context)
|
||
const businessSegment = pickBusinessReasonSegment(withoutContext)
|
||
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
|
||
if (isSystemGeneratedReasonText(withoutContext)) return ''
|
||
return withoutContext
|
||
}
|
||
|
||
function isApplicationPreviewValueProvided(value) {
|
||
const normalized = String(value || '').trim()
|
||
return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
|
||
}
|
||
|
||
function resolveProvidedValue(value, fallback = '') {
|
||
return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
|
||
}
|
||
|
||
function normalizeApplicationTypeLabel(value, fallback = '') {
|
||
const label = String(value || '').trim()
|
||
if (!label || label === '其他费用') return fallback || '费用申请'
|
||
if (label.endsWith('费用申请') || label.endsWith('申请')) return label
|
||
if (label.endsWith('费用')) return `${label}申请`
|
||
if (label.endsWith('费')) return `${label.slice(0, -1)}费用申请`
|
||
return `${label}申请`
|
||
}
|
||
|
||
export function normalizeTransportModeOption(value, fallback = '') {
|
||
const text = String(value || '').trim()
|
||
if (/飞机|机票|航班/.test(text)) return '飞机'
|
||
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
|
||
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
|
||
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
|
||
}
|
||
|
||
function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', currentFields = {}) {
|
||
const currentTransportMode = isApplicationPreviewValueProvided(currentFields.transportMode)
|
||
? String(currentFields.transportMode).trim()
|
||
: ''
|
||
const explicitTransportMode = resolveApplicationTransportMode(rawText)
|
||
if (!explicitTransportMode) {
|
||
return currentTransportMode
|
||
}
|
||
|
||
const ontologyTransportMode = normalizeTransportModeOption(ontologyFields.transportMode, '')
|
||
if (ontologyTransportMode && ontologyTransportMode === explicitTransportMode) {
|
||
return ontologyTransportMode
|
||
}
|
||
return currentTransportMode || explicitTransportMode
|
||
}
|
||
|
||
function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||
const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 0)
|
||
if (Number.isFinite(numericAmount) && numericAmount > 0) {
|
||
return `${numericAmount}元`
|
||
}
|
||
|
||
const display = String(fields.amountDisplay || '').trim()
|
||
if (display && display !== '待补充') {
|
||
const normalized = display.replace(/^¥\s*/, '').replace(/,/g, '').trim()
|
||
return normalized.endsWith('元') ? normalized : `${normalized}元`
|
||
}
|
||
|
||
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)
|
||
.filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
|
||
.map((item) => resolveApplicationFieldLabel(item, fields))
|
||
}
|
||
|
||
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
const fields = normalized.fields || {}
|
||
const days = parseApplicationDaysValue(fields.days)
|
||
const location = String(fields.location || '').trim()
|
||
const grade = String(fields.grade || resolveCurrentUserGrade(currentUser)).trim()
|
||
const applicationType = String(fields.applicationType || '').trim()
|
||
const transportMode = String(fields.transportMode || '').trim()
|
||
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
|
||
|
||
if (!shouldEstimate || !days || !location) {
|
||
return {
|
||
canCalculate: false,
|
||
reason: '缺少地点或天数',
|
||
payload: null
|
||
}
|
||
}
|
||
|
||
return {
|
||
canCalculate: true,
|
||
reason: '',
|
||
payload: {
|
||
days,
|
||
location,
|
||
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 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()
|
||
if (isTravelApplicationType(fields.applicationType) && !String(fields.transportMode || '').trim()) {
|
||
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
|
||
const baseTotalAmount = parseMoneyNumber(result?.hotel_amount) + parseMoneyNumber(result?.allowance_amount)
|
||
const baseTotalDisplay = Number.isFinite(baseTotalAmount) && baseTotalAmount > 0
|
||
? formatPolicyMoney(baseTotalAmount)
|
||
: ''
|
||
return normalizeApplicationPreview({
|
||
...preview,
|
||
fields: {
|
||
...fields,
|
||
grade,
|
||
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
||
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
||
transportPolicy: APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
|
||
policyEstimate: baseTotalDisplay
|
||
? `交通待补充 + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${baseTotalDisplay}元(${days}天,不含交通)`
|
||
: APPLICATION_POLICY_PENDING_TEXT,
|
||
amount: baseTotalDisplay ? `${baseTotalDisplay}元(不含交通)` : fields.amount,
|
||
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: baseTotalDisplay ? `${baseTotalDisplay}元(不含交通)` : ''
|
||
},
|
||
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}元 + `
|
||
: ''
|
||
const totalAmount = systemEstimate.totalAmountDisplay
|
||
const amount = totalAmount ? `${totalAmount}元` : fields.amount
|
||
|
||
return normalizeApplicationPreview({
|
||
...preview,
|
||
fields: {
|
||
...fields,
|
||
grade,
|
||
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
|
||
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
|
||
transportPolicy: buildTransportPolicyText(fields.transportMode, matchedCity || fields.location, transportEstimate, fields.time),
|
||
policyEstimate: `${transportText}住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`,
|
||
amount,
|
||
matchedCity,
|
||
ruleName: String(result?.rule_name || '').trim(),
|
||
ruleVersion: String(result?.rule_version || '').trim(),
|
||
hotelAmount: hotelAmount ? `${hotelAmount}元` : '',
|
||
allowanceAmount: allowanceAmount ? `${allowanceAmount}元` : '',
|
||
transportEstimatedAmount: systemEstimate.transportAmountDisplay ? `${systemEstimate.transportAmountDisplay}元` : '',
|
||
transportEstimateDate: transportEstimate?.queryDate || '',
|
||
transportQueryLatencyMs: transportEstimate?.simulatedLatencyMs ? `${transportEstimate.simulatedLatencyMs}ms` : '',
|
||
transportEstimateSource: transportEstimate?.source || '',
|
||
transportEstimateConfidence: transportEstimate?.confidence || '',
|
||
policyTotalAmount: totalAmount ? `${totalAmount}元` : ''
|
||
},
|
||
policyEstimate: {
|
||
...result,
|
||
grade,
|
||
matchedCity,
|
||
transport_estimate: transportEstimate,
|
||
system_total_amount: systemEstimate.totalAmount
|
||
},
|
||
policyEstimateStatus: 'completed'
|
||
})
|
||
}
|
||
|
||
export function refreshApplicationPreviewTransportEstimate(preview = {}) {
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
const fields = { ...(normalized.fields || {}) }
|
||
const policyResult = normalized.policyEstimate && typeof normalized.policyEstimate === 'object'
|
||
? normalized.policyEstimate
|
||
: {}
|
||
const location = String(fields.matchedCity || policyResult.matched_city || fields.location || '').trim()
|
||
const hotelAmountSource = fields.hotelAmount || policyResult.hotel_amount || 0
|
||
const allowanceAmountSource = fields.allowanceAmount || policyResult.allowance_amount || 0
|
||
const systemEstimate = buildSystemApplicationEstimate({
|
||
transportMode: fields.transportMode,
|
||
location,
|
||
time: fields.time,
|
||
lodgingAmount: hotelAmountSource,
|
||
allowanceAmount: allowanceAmountSource
|
||
})
|
||
const transportEstimate = systemEstimate.transportEstimate
|
||
if (!transportEstimate) return normalized
|
||
|
||
const hotelAmount = formatPolicyMoney(hotelAmountSource)
|
||
const allowanceAmount = formatPolicyMoney(allowanceAmountSource)
|
||
const hasPolicyAmounts = parseMoneyNumber(hotelAmountSource) > 0 || parseMoneyNumber(allowanceAmountSource) > 0
|
||
const nextFields = {
|
||
...fields,
|
||
transportPolicy: buildTransportPolicyText(fields.transportMode, location, transportEstimate, fields.time),
|
||
transportEstimatedAmount: systemEstimate.transportAmountDisplay ? `${systemEstimate.transportAmountDisplay}元` : '',
|
||
transportEstimateDate: transportEstimate.queryDate || '',
|
||
transportQueryLatencyMs: transportEstimate.simulatedLatencyMs ? `${transportEstimate.simulatedLatencyMs}ms` : '',
|
||
transportEstimateSource: transportEstimate.source || '',
|
||
transportEstimateConfidence: transportEstimate.confidence || ''
|
||
}
|
||
|
||
if (hasPolicyAmounts) {
|
||
const days = Number(policyResult.days) || parseApplicationDaysValue(fields.days) || 1
|
||
const totalAmount = systemEstimate.totalAmountDisplay
|
||
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}元 + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
|
||
nextFields.amount = totalAmount ? `${totalAmount}元` : nextFields.amount
|
||
nextFields.policyTotalAmount = totalAmount ? `${totalAmount}元` : ''
|
||
}
|
||
|
||
return normalizeApplicationPreview({
|
||
...normalized,
|
||
fields: nextFields,
|
||
policyEstimate: {
|
||
...policyResult,
|
||
matchedCity: location,
|
||
transport_estimate: transportEstimate,
|
||
system_total_amount: systemEstimate.totalAmount
|
||
}
|
||
})
|
||
}
|
||
|
||
export function applyApplicationPolicyEstimateError(preview = {}, error = null, currentUser = {}) {
|
||
const fields = { ...(preview?.fields || {}) }
|
||
const message = String(error?.message || error || '').trim()
|
||
return normalizeApplicationPreview({
|
||
...preview,
|
||
fields: {
|
||
...fields,
|
||
grade: fields.grade || resolveCurrentUserGrade(currentUser),
|
||
transportPolicy: buildTransportPolicyText(fields.transportMode, fields.location, null, fields.time),
|
||
policyEstimate: message ? `规则中心暂未完成测算:${message}` : APPLICATION_POLICY_PENDING_TEXT
|
||
},
|
||
policyEstimateStatus: message ? 'failed' : 'pending'
|
||
})
|
||
}
|
||
|
||
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
|
||
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,
|
||
validationIssues,
|
||
readyToSubmit: missingFields.length === 0 && validationIssues.length === 0
|
||
}
|
||
}
|
||
|
||
export function applyApplicationBusinessTimeContext(preview = {}, businessTimeContext = null) {
|
||
if (!businessTimeContext || typeof businessTimeContext !== 'object') {
|
||
return normalizeApplicationPreview(preview)
|
||
}
|
||
|
||
const startDate = String(businessTimeContext.start_date || '').trim()
|
||
const endDate = String(businessTimeContext.end_date || startDate).trim()
|
||
const displayValue = String(
|
||
businessTimeContext.business_time ||
|
||
businessTimeContext.time_range ||
|
||
businessTimeContext.display_value ||
|
||
''
|
||
).trim()
|
||
const time = startDate && endDate
|
||
? (startDate === endDate ? startDate : `${startDate} 至 ${endDate}`)
|
||
: displayValue
|
||
if (!time) {
|
||
return normalizeApplicationPreview(preview)
|
||
}
|
||
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
const fields = normalized.fields || {}
|
||
return normalizeApplicationPreview({
|
||
...normalized,
|
||
fields: {
|
||
...fields,
|
||
time,
|
||
days: resolveDaysFromDateRange(time) || fields.days
|
||
}
|
||
})
|
||
}
|
||
|
||
export function buildModelRefinedApplicationPreview(localPreview = {}, ontology = {}, rawText = '', currentUser = {}) {
|
||
const currentFields = localPreview?.fields || {}
|
||
const ontologyFields = buildApplicationFieldsFromOntology(ontology || {}, rawText, currentUser)
|
||
const parseStrategy = String(ontology?.parse_strategy || '').trim()
|
||
const refinedFields = {
|
||
...currentFields,
|
||
applicationType: normalizeApplicationTypeLabel(
|
||
ontologyFields.expenseTypeLabel,
|
||
currentFields.applicationType
|
||
),
|
||
time: resolveProvidedValue(ontologyFields.timeRange, currentFields.time),
|
||
location: resolveProvidedValue(ontologyFields.location, currentFields.location),
|
||
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
|
||
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)),
|
||
position: resolveProvidedValue(currentFields.position, resolveCurrentUserPosition(currentUser)),
|
||
managerName: resolveProvidedValue(
|
||
ontologyFields.managerName,
|
||
currentFields.managerName || resolveCurrentUserManagerName(currentUser)
|
||
)
|
||
}
|
||
|
||
return normalizeApplicationPreview({
|
||
...localPreview,
|
||
sourceText: String(rawText || localPreview.sourceText || '').trim(),
|
||
fields: refinedFields,
|
||
modelRefined: true,
|
||
parseStrategy,
|
||
modelReviewStatus: parseStrategy === 'llm_primary' ? 'completed' : 'fallback'
|
||
})
|
||
}
|
||
|
||
export function buildApplicationPreviewRows(preview = {}) {
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
const fields = normalized.fields || {}
|
||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.flatMap((item) => {
|
||
if (item.key === 'time' && isTravelApplicationType(fields.applicationType)) {
|
||
const tripDates = resolveApplicationTripDateParts(fields)
|
||
const rawValue = fields[item.key]
|
||
const missing = item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
||
return [
|
||
{
|
||
...item,
|
||
label: '出发时间',
|
||
value: tripDates.startDate || '待补充',
|
||
editable: item.editable !== false,
|
||
highlight: Boolean(item.highlight),
|
||
missing
|
||
},
|
||
{
|
||
key: 'time_return',
|
||
label: '返回时间',
|
||
value: tripDates.endDate || '待补充',
|
||
editable: false,
|
||
highlight: Boolean(item.highlight),
|
||
missing
|
||
}
|
||
]
|
||
}
|
||
|
||
const rawValue = fields[item.key]
|
||
const value = String(rawValue || '').trim() || '待补充'
|
||
return [{
|
||
...item,
|
||
label: resolveApplicationFieldLabel(item, fields),
|
||
value,
|
||
editable: item.editable !== false,
|
||
highlight: Boolean(item.highlight),
|
||
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
||
}]
|
||
})
|
||
}
|
||
|
||
export function buildApplicationPreviewSubmitText(preview = {}) {
|
||
const rows = buildApplicationPreviewRows(preview)
|
||
return [
|
||
'费用申请确认提交',
|
||
...rows.map((row) => `${row.label}:${row.value}`),
|
||
'',
|
||
'确认提交'
|
||
].join('\n')
|
||
}
|
||
|
||
export function buildLocalApplicationPreview(rawText, currentUser = {}, options = {}) {
|
||
const sourceText = String(rawText || '').trim()
|
||
const explicitDays = resolveApplicationDays(sourceText)
|
||
const time = resolveApplicationTimeWithDefault(sourceText, explicitDays, options)
|
||
const days = explicitDays || resolveDaysFromDateRange(time)
|
||
const location = resolveApplicationLocation(sourceText)
|
||
const fields = {
|
||
applicationType: resolveApplicationType(sourceText),
|
||
time,
|
||
location,
|
||
reason: resolveApplicationReason(sourceText, { location }),
|
||
days,
|
||
transportMode: resolveApplicationTransportMode(sourceText),
|
||
amount: resolveApplicationAmount(sourceText),
|
||
grade: resolveCurrentUserGrade(currentUser),
|
||
applicant: currentUser.name || currentUser.username || '当前用户',
|
||
department: resolveCurrentUserDepartment(currentUser) || '待补充',
|
||
position: resolveCurrentUserPosition(currentUser) || '待补充',
|
||
managerName: resolveCurrentUserManagerName(currentUser) || '待补充'
|
||
}
|
||
|
||
return normalizeApplicationPreview({
|
||
sourceText,
|
||
fields,
|
||
modelReviewStatus: 'local'
|
||
})
|
||
}
|
||
|
||
export function buildApplicationTemplatePreview(currentUser = {}) {
|
||
return normalizeApplicationPreview({
|
||
sourceText: '快速发起申请',
|
||
fields: {
|
||
applicationType: '费用申请',
|
||
time: '',
|
||
location: '',
|
||
reason: '',
|
||
days: '',
|
||
transportMode: '',
|
||
amount: '',
|
||
grade: resolveCurrentUserGrade(currentUser),
|
||
applicant: currentUser.name || currentUser.username || '当前用户',
|
||
department: resolveCurrentUserDepartment(currentUser) || '待补充',
|
||
position: resolveCurrentUserPosition(currentUser) || '待补充',
|
||
managerName: resolveCurrentUserManagerName(currentUser) || '待补充'
|
||
},
|
||
modelReviewStatus: 'template'
|
||
})
|
||
}
|
||
|
||
export function buildLocalApplicationPreviewMessage(preview) {
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
|
||
return [
|
||
modelReviewStatus === 'completed'
|
||
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'
|
||
: modelReviewStatus === 'fallback'
|
||
? '模型复核没有返回稳定结果,我已先按规则兜底整理成下方表格。请重点核查识别结果;点击对应行即可直接编辑。'
|
||
: modelReviewStatus === 'failed'
|
||
? '模型复核暂时失败,我先保留一份临时核对表,方便您核查和补充信息。点击对应行即可直接编辑。'
|
||
: modelReviewStatus === 'template'
|
||
? '我已为你准备好费用申请模板。本步骤不调用大模型,也不会保存草稿;请点击对应行直接填写。'
|
||
: '我先整理出下方表格,请核查识别结果。点击对应行即可直接编辑。'
|
||
].join('\n')
|
||
}
|
||
|
||
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('、')}。补齐后我再帮您提交申请。`
|
||
}
|
||
|
||
return '请确认上述的信息是否填写正确?如果准确无误,点击 [确认](#application-submit) 进入审批环节。'
|
||
}
|