feat: 完善文档中心与报销申请交互及侧边栏重构
后端优化编排器报销查询和本体检测精度,增强报销单草稿保 存和附件回填逻辑,前端重构侧边栏组件支持折叠和图标导 航,完善文档中心状态筛选和详情提示,报销创建和审批详情 页优化会话管理和费用明细交互,新增助手应用服务和预设动 作工具函数,补充单元测试覆盖。
This commit is contained in:
@@ -16,7 +16,10 @@ const SLOT_LABELS = {
|
||||
expense_type: '费用场景',
|
||||
amount: '申请金额',
|
||||
time_range: '业务时间',
|
||||
location: '业务地点',
|
||||
reason: '申请事由',
|
||||
days: '天数',
|
||||
transport_mode: '出行方式',
|
||||
attachments: '附件说明',
|
||||
customer_name: '客户名称',
|
||||
participants: '参与人员'
|
||||
@@ -24,6 +27,33 @@ const SLOT_LABELS = {
|
||||
|
||||
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元',
|
||||
@@ -89,6 +119,112 @@ export function resolveTimeRangeText(ontology) {
|
||||
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)}`
|
||||
}
|
||||
|
||||
export function resolveApplicationTimeRange(ontology, prompt) {
|
||||
const range = ontology?.time_range || {}
|
||||
const baseTime = resolveTimeRangeText(ontology)
|
||||
|| 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(/[,。;;]$/, '') : ''
|
||||
}
|
||||
|
||||
export function resolveApplicationReason(prompt) {
|
||||
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
|
||||
return labeled || String(prompt || '').trim()
|
||||
}
|
||||
|
||||
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
|
||||
const code = String(expenseTypeCode || '').trim()
|
||||
if (ATTACHMENT_REQUIRED_TYPES.has(code)) {
|
||||
@@ -128,8 +264,15 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
||||
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 = locationEntity?.normalized_value
|
||||
|| locationEntity?.value
|
||||
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点'])
|
||||
|| '待补充'
|
||||
const reason = resolveApplicationReason(prompt) || '待补充'
|
||||
|
||||
return {
|
||||
const fields = {
|
||||
documentType: documentTypeEntity?.normalized_value || 'expense_application',
|
||||
documentTypeLabel: documentTypeEntity?.value || '费用申请',
|
||||
workflowStage: workflowStageEntity?.normalized_value || 'pre_approval',
|
||||
@@ -138,21 +281,40 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
|
||||
expenseTypeLabel: resolveExpenseTypeLabel(expenseTypeCode),
|
||||
amount: amount.value,
|
||||
amountDisplay: amount.value ? `¥${amount.value.toLocaleString('zh-CN')}` : '待补充',
|
||||
timeRange: resolveTimeRangeText(ontology) || '待补充',
|
||||
location: locationEntity?.normalized_value || locationEntity?.value || '待补充',
|
||||
reason: String(prompt || '').trim() || '待补充',
|
||||
timeRange,
|
||||
location,
|
||||
reason,
|
||||
applicant: currentUser.name || currentUser.username || '当前用户',
|
||||
department: currentUser.department || currentUser.departmentName || '待补充',
|
||||
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),
|
||||
attachmentPolicy,
|
||||
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [])
|
||||
attachmentPolicy
|
||||
}
|
||||
|
||||
return {
|
||||
...fields,
|
||||
missingSlots: normalizeMissingSlots(ontology?.missing_slots || [], fields)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMissingSlots(slots = []) {
|
||||
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)
|
||||
})).filter((item) => item.key && !isSlotAlreadyResolved(item.key, fields))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user