Files
X-Financial/web/src/utils/expenseApplicationOntology.js
caoxiaozhu e7bef0883d feat: 新增预算后端服务与差旅风险规则库
后端新增预算模型、端点和服务模块,支持预算 CRUD 和余额
查询,清理旧生成规则文件并替换为按严重等级分类的差旅风
险规则库,优化认证权限和报销单访问策略,新增财务规则目
录和演示数据构建脚本,前端预算中心增加对话框交互,完善
审计页面运行时模型和元数据展示,补充单元测试。
2026-05-26 17:29:35 +08:00

419 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(/(?<days>\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() + dayCount)
return `${formatApplicationDate(startDate)}${formatApplicationDate(endDate)}`
}
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))
}