const EXPENSE_TYPE_LABELS = { travel: '差旅费', hotel: '住宿费', transport: '交通费', meal: '业务招待费', entertainment: '业务招待费', meeting: '会务费', marketing: '市场推广费', office: '办公用品费', training: '培训费', software: '软件服务费', communication: '通讯费', welfare: '福利费', other: '其他费用' } const SLOT_LABELS = { expense_type: '费用场景', amount: '申请金额', time_range: '业务时间', location: '业务地点', reason: '申请事由', days: '天数', transport_mode: '出行方式', attachments: '附件说明', customer_name: '客户名称', participants: '参与人员' } const PRE_APPROVAL_TYPES = new Set(['travel', 'meeting', 'office', 'training']) const ATTACHMENT_REQUIRED_TYPES = new Set(['meeting', 'training']) const PLACEHOLDER_VALUES = new Set(['', '待补充', '暂无', '无', '未知']) const PROMPT_FIELD_LABELS = [ '发生时间', '业务发生时间', '申请时间', '时间', '地点', '业务地点', '发生地点', '事由', '申请事由', '出差事由', '原因', '用途', '天数', '出差天数', '申请天数', '出行方式', '交通方式', '交通工具', '用户预估费用', '预估费用', '预计总费用', '预计费用', '预计金额', '申请金额', '预算', '金额' ] export const APPLICATION_EXAMPLES = [ '申请下周去北京做客户现场验收,预计费用18000元', '申请上海产品发布会会务费32000元,需要场地和物料', '申请部门集中采购办公用品4800元,用于新员工入职' ] export function buildExpenseApplicationOntologyContext(currentUser = {}) { return { document_type: 'expense_application', application_stage: 'pre_approval', conversation_scenario: 'expense', entry_source: 'documents_application', role_codes: Array.isArray(currentUser.roleCodes) ? currentUser.roleCodes : [], is_admin: Boolean(currentUser.isAdmin), name: currentUser.name || '', role: currentUser.role || '', department: currentUser.department || currentUser.departmentName || '', department_name: currentUser.department || currentUser.departmentName || '', position: currentUser.position || '', grade: currentUser.grade || '', employee_no: currentUser.employeeNo || currentUser.employee_no || '' } } export function resolveEntity(ontology, type) { const entities = Array.isArray(ontology?.entities) ? ontology.entities : [] return entities.find((item) => item?.type === type) || null } export function resolveConstraint(ontology, field) { const constraints = Array.isArray(ontology?.constraints) ? ontology.constraints : [] return constraints.find((item) => item?.field === field) || null } export function resolveExpenseTypeCode(ontology) { const entity = resolveEntity(ontology, 'expense_type') return String(entity?.normalized_value || entity?.value || 'other').trim() || 'other' } export function resolveExpenseTypeLabel(code) { return EXPENSE_TYPE_LABELS[String(code || '').trim()] || EXPENSE_TYPE_LABELS.other } export function resolveApplicationAmount(ontology) { const amountEntity = resolveEntity(ontology, 'amount') const amountConstraint = resolveConstraint(ontology, 'amount') const rawValue = amountEntity?.normalized_value || amountEntity?.value || amountConstraint?.value || '' const numericValue = Number(String(rawValue).replace(/[^\d.]/g, '')) return { raw: String(rawValue || '').trim(), value: Number.isFinite(numericValue) ? numericValue : 0 } } export function resolveTimeRangeText(ontology) { const range = ontology?.time_range || {} if (range.start_date && range.end_date) { return range.start_date === range.end_date ? range.start_date : `${range.start_date} 至 ${range.end_date}` } return String(range.raw || '').trim() } function parseApplicationDate(value) { const normalized = String(value || '') .trim() .replace(/日$/, '') .replace(/年|月|\//g, '-') .replace(/\./g, '-') const match = normalized.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))) if (Number.isNaN(date.getTime())) return null return date } function formatApplicationDate(date) { return date.toISOString().slice(0, 10) } function parseChineseNumber(value) { const digits = { 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9 } const text = String(value || '').trim() 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 } export function resolvePromptDays(prompt) { const labeled = resolvePromptField(prompt, ['天数', '出差天数', '申请天数']) const source = labeled || String(prompt || '') const match = source.match(/(?\d+|[一二两三四五六七八九十]{1,3})\s*天/) if (!match?.groups?.days) return 0 if (/^\d+$/.test(match.groups.days)) return Number(match.groups.days) return parseChineseNumber(match.groups.days) } export function expandApplicationTimeWithDays(timeText, days = 0) { const normalizedTime = String(timeText || '').trim() const dayCount = Number(days || 0) if (!normalizedTime || !dayCount) return normalizedTime if (/\s*(至|到|~|--|—)\s*/.test(normalizedTime)) return normalizedTime const match = normalizedTime.match(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/) const startDate = parseApplicationDate(match?.[0] || '') if (!startDate) return normalizedTime const endDate = new Date(startDate.getTime()) endDate.setUTCDate(endDate.getUTCDate() + Math.max(dayCount - 1, 0)) const startText = formatApplicationDate(startDate) const endText = formatApplicationDate(endDate) return startText === endText ? startText : `${startText} 至 ${endText}` } function normalizeApplicationTimeCandidate(value) { const text = String(value || '').trim().replace(/^[,,、。;;\s]+/, '') if (!text) return '' if (/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/.test(text)) return text if (/今天|明天|后天|本周|下周|上周|本月|下月|月底|月初/.test(text)) return text return '' } export function resolveApplicationTimeRange(ontology, prompt) { const range = ontology?.time_range || {} const baseTime = normalizeApplicationTimeCandidate(resolveTimeRangeText(ontology)) || normalizeApplicationTimeCandidate(resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间'])) if (range.start_date && range.end_date && range.start_date !== range.end_date) { return `${range.start_date} 至 ${range.end_date}` } return expandApplicationTimeWithDays(baseTime, resolvePromptDays(prompt)) || baseTime } function escapeRegExp(value) { return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } export function resolvePromptField(prompt, labels = []) { const text = String(prompt || '').trim() if (!text) return '' const labelSet = new Set(labels.map((item) => String(item || '').trim()).filter(Boolean)) for (const line of text.split(/\r?\n/)) { const match = line.match(/^\s*([^::\s]+)\s*[::]\s*(.+?)\s*$/) if (match && labelSet.has(match[1].trim())) { return match[2].trim() } } const labelPattern = labels.map(escapeRegExp).join('|') const nextLabelPattern = PROMPT_FIELD_LABELS.map(escapeRegExp).join('|') if (!labelPattern) return '' const match = text.match( new RegExp(`(?:${labelPattern})\\s*[::]\\s*([\\s\\S]*?)(?=\\s*(?:${nextLabelPattern})\\s*[::]|$)`) ) return match ? match[1].trim().replace(/[,。;;]$/, '') : '' } function normalizeApplicationTransportMode(value) { const text = String(value || '').trim() if (/飞机|机票|航班/.test(text)) return '飞机' if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船' if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车' return text } function cleanupApplicationReasonCandidate(value, location = '') { let text = String(value || '').trim() if (!text) return '' text = text .replace(/^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[::]\s*/u, '') .replace(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/gu, '') .replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '') .replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '') .replace(/(?:高铁|动车|火车|铁路|列车|飞机|机票|航班|轮船|船票|客轮|渡轮|邮轮)/gu, '') .replace(/[,,、。;;]+/g, ',') .replace(/^\s*(申请|费用申请|业务|本次|去|到|前往|赴)\s*/u, '') .replace(/^[,\s]+|[,\s]+$/g, '') .trim() const normalizedLocation = String(location || '').trim() if (normalizedLocation) { const escapedLocation = escapeRegExp(normalizedLocation) text = text .replace(new RegExp(`^${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '') .replace(new RegExp(`^(?:去|到|前往|赴)${escapedLocation}(?:出差)?(?:,|,|、)?`, 'u'), '') .trim() } if (!text) return '' if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return '' if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return '' if (/^[\u4e00-\u9fa5]{1,8}$/.test(text) && !/服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(text)) { return '' } return text } function resolveApplicationLocationText(ontology, prompt) { const locationEntity = resolveEntity(ontology, 'location') return locationEntity?.normalized_value || locationEntity?.value || resolvePromptField(prompt, ['地点', '业务地点', '发生地点', '目的地']) || '' } export function resolveApplicationReason(prompt, ontology = null) { const location = resolveApplicationLocationText(ontology, prompt) const reasonEntity = resolveEntity(ontology, 'reason') || resolveEntity(ontology, 'business_reason') const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim() if (entityReason) { return cleanupApplicationReasonCandidate(entityReason, location) || entityReason } const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途']) if (labeled) { return cleanupApplicationReasonCandidate(labeled, location) || labeled } const candidates = String(prompt || '') .split(/[\n,。;;]+/u) .map((item) => cleanupApplicationReasonCandidate(item, location)) .filter(Boolean) const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) return businessCandidate || candidates.sort((left, right) => right.length - left.length)[0] || '' } function resolveApplicationTransportMode(ontology, prompt) { const transportEntity = resolveEntity(ontology, 'transport_mode') || resolveEntity(ontology, 'transport') const fromEntity = normalizeApplicationTransportMode( transportEntity?.normalized_value || transportEntity?.value || '' ) if (fromEntity) return fromEntity const labeled = resolvePromptField(prompt, ['出行方式', '交通方式', '交通工具']) const fromLabel = normalizeApplicationTransportMode(labeled) if (fromLabel) return fromLabel const text = String(prompt || '') if (/飞机|机票|航班/.test(text)) return '飞机' if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船' if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车' return '' } export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) { const code = String(expenseTypeCode || '').trim() if (ATTACHMENT_REQUIRED_TYPES.has(code)) { return { level: 'required', label: '必须提交', description: code === 'meeting' ? '需补充会议通知、议程、参会范围或预算说明。' : '需补充培训通知、课程说明、报价或审批依据。' } } if (code === 'office' && amount >= 5000) { return { level: 'required', label: '必须提交', description: '办公采购金额较高,需补充采购清单、报价或预算说明。' } } if (code === 'travel') { return { level: 'optional', label: '说明可选', description: '可先提交出差目的、时间和预算;行程或邀请材料可作为补充说明。' } } return { level: 'none', label: '无需附件', description: '当前申请事项可先不提交附件,后续报销阶段再按票据要求补充。' } } export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) { const expenseTypeCode = resolveExpenseTypeCode(ontology) const amount = resolveApplicationAmount(ontology) const documentTypeEntity = resolveEntity(ontology, 'document_type') const workflowStageEntity = resolveEntity(ontology, 'workflow_stage') const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value) const timeRange = resolveApplicationTimeRange(ontology, prompt) || '待补充' const location = resolveApplicationLocationText(ontology, prompt) || '待补充' const reason = resolveApplicationReason(prompt, ontology) || '待补充' const days = resolvePromptDays(prompt) const transportMode = resolveApplicationTransportMode(ontology, prompt) const fields = { documentType: documentTypeEntity?.normalized_value || 'expense_application', documentTypeLabel: documentTypeEntity?.value || '费用申请', workflowStage: workflowStageEntity?.normalized_value || 'pre_approval', workflowStageLabel: workflowStageEntity?.value || '前置申请', expenseTypeCode, expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode), amount: amount.value, amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充', timeRange, location, reason, days: days ? `${days}天` : '', transportMode, applicant: currentUser.name || currentUser.username || '当前用户', department: currentUser.department || currentUser.departmentName || '待补充', preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode), attachmentPolicy } return { ...fields, missingSlots: normalizeMissingSlots(ontology?.missing_slots || [], fields) } } function hasProvidedValue(value) { const normalized = String(value || '').trim() return !PLACEHOLDER_VALUES.has(normalized) } function isSlotAlreadyResolved(slot, fields = {}) { const key = String(slot || '').trim() if (key === 'reason') return hasProvidedValue(fields.reason) if (key === 'time_range' || key === 'time') return hasProvidedValue(fields.timeRange) if (key === 'location') return hasProvidedValue(fields.location) if (key === 'amount') return Number(fields.amount || 0) > 0 if (key === 'transport_mode') return hasProvidedValue(fields.transportMode) return false } export function normalizeMissingSlots(slots = [], fields = {}) { const normalized = Array.isArray(slots) ? slots : [] return normalized.map((item) => ({ key: String(item || '').trim(), label: SLOT_LABELS[String(item || '').trim()] || String(item || '').trim() })).filter((item) => item.key && !isSlotAlreadyResolved(item.key, fields)) }