import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js' import { getTodayDateValue } from './workbenchComposerDate.js' const APPLICATION_SESSION_TYPE = 'application' const APPLICATION_CREATE_PATTERN = /申请|事前|前置|预算|出差|差旅|采购|会务|会议|培训|办公用品|交通|住宿/ const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [ { key: 'applicationType', label: '申请类型' }, { key: 'grade', label: '职级', highlight: true, 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 } ] export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船'] const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算' const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '车票、机票暂无实时价格接口,按真实票据实报实销' function compactText(value) { return String(value || '').replace(/\s+/g, '') } 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 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}天` : '' } 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 (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.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*(?:元|块|人民币)?/u, /(?\d+(?:\.\d+)?)\s*(?:元|块|人民币)/u ]) if (labeled) return `${labeled}元` if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算' return '' } function resolveCurrentUserGrade(currentUser = {}) { return String( currentUser.grade || currentUser.employeeGrade || currentUser.employee_grade || currentUser.profileGrade || '' ).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) { const mode = String(transportMode || '').trim() if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT return `${mode}票据暂无实时价格接口,按真实票据实报实销` } 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) } 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 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) 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 resolveApplicationTransportMode(text) { 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*(?=[,,、。;;\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(Boolean) return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || '' } 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) 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}申请` } 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 normalizeAmountFromOntology(fields = {}, fallback = '') { const numericAmount = Number(fields.amount || 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 buildMissingFields(fields) { return APPLICATION_PREVIEW_FIELD_DEFINITIONS .filter((item) => item.key !== 'applicationType' && item.required !== false) .filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key])) .map((item) => item.label) } 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 } } } export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) { const fields = { ...(preview?.fields || {}) } const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1 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 totalAmount = formatPolicyMoney(result?.total_amount) const matchedCity = String(result?.matched_city || fields.location || '').trim() const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim() return normalizeApplicationPreview({ ...preview, fields: { ...fields, grade, lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate), subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate), transportPolicy: buildTransportPolicyText(fields.transportMode), policyEstimate: `住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天,不含交通票据)`, matchedCity, ruleName: String(result?.rule_name || '').trim(), ruleVersion: String(result?.rule_version || '').trim(), hotelAmount: hotelAmount ? `${hotelAmount}元` : '', allowanceAmount: allowanceAmount ? `${allowanceAmount}元` : '', policyTotalAmount: totalAmount ? `${totalAmount}元` : '' }, policyEstimate: { ...result, grade, matchedCity }, policyEstimateStatus: 'completed' }) } 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), policyEstimate: message ? `规则中心暂未完成测算:${message}` : APPLICATION_POLICY_PENDING_TEXT }, policyEstimateStatus: message ? 'failed' : 'pending' }) } export function shouldUseLocalApplicationPreview(rawText, options = {}) { if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) return false if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) return false const compact = compactText(rawText) if (!compact || APPLICATION_QUERY_PATTERN.test(compact)) return false return APPLICATION_CREATE_PATTERN.test(compact) } export function normalizeApplicationPreview(preview = {}) { const fields = ensureApplicationPolicyFields(preview?.fields || {}) const missingFields = buildMissingFields(fields) return { ...preview, fields, missingFields, readyToSubmit: missingFields.length === 0 } } 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: normalizeTransportModeOption( ontologyFields.transportMode, currentFields.transportMode ), amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount), grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)), applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant), department: resolveProvidedValue(ontologyFields.department, currentFields.department) } 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.map((item) => { const rawValue = fields[item.key] const value = String(rawValue || '').trim() || '待补充' return { ...item, 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: currentUser.department || currentUser.departmentName || '待补充' } 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: currentUser.department || currentUser.departmentName || '待补充' }, 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 : [] if (missingFields.length) { return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。` } return '请确认上述的信息是否填写正确?如果准确无误,点击 [确认](#application-submit) 进入审批环节。' }