Files
X-Financial/web/src/utils/expenseApplicationOntology.js
caoxiaozhu 0c74b4ab4a feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
2026-06-02 16:22:59 +08:00

437 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 || '',
manager_name: currentUser.managerName || currentUser.manager_name || '',
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'
}
function looksLikeStructuredTravelApplication(prompt) {
const text = String(prompt || '')
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[:]/.test(text)
&& /(?:地点|业务地点|发生地点|目的地)\s*[:]/.test(text)
&& /(?:天数|出差天数|申请天数)\s*[:]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(text)
}
function resolveApplicationExpenseTypeCode(ontology, prompt) {
const code = resolveExpenseTypeCode(ontology)
if (code !== 'other') return code
return looksLikeStructuredTravelApplication(prompt) ? 'travel' : code
}
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() + 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 = resolveApplicationExpenseTypeCode(ontology, prompt)
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 || '待补充',
position: currentUser.position || currentUser.employeePosition || '待补充',
managerName: currentUser.managerName || currentUser.manager_name || '待补充',
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))
}