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( /(?\d{1,2})月(?\d{1,2})日?\s*(?:至|到|~|—|–|--|-)\s*(?:(?\d{1,2})月)?(?\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*(?\d+(?:\.\d+)?\s*(?:万|千|k|K)?)/u, /(?\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(/(?\d+(?:\.\d+)?)\s*(?万|千|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*(?[^。;;\n,,]+)/u ]) if (labeled) candidates.push(normalizeLocationCandidate(labeled)) const compact = compactText(text) const patterns = [ /(?:去|到|赴|前往)(?[\u4e00-\u9fa5]{1,24})/gu, /(?[\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*(?[^。;;\n,,]+)/u ])), /高铁|动车|火车|铁路/.test(compact) ? '火车' : '', /飞机|机票|航班/.test(compact) ? '飞机' : '', /轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : '' ]) } function extractApplicationAmountCandidates(text) { const candidates = [] const source = String(text || '') const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu for (const match of source.matchAll(labelPattern)) { candidates.push(normalizeApplicationAmountText(match.groups?.value || '')) } const amountPattern = /(?\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, [ /(?:出差|申请)?(?\d+)\s*天/u, /(?\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*(?20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u, /(?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*(?[^。;;\n]+)/u, /(?:去|到|前往)(?[\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*(?[^。;;\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*(?[^,。;;\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) 进入审批环节。' }