feat: 增强差旅报销审核流程与票据智能推理

优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 16:09:47 +08:00
parent f28d7e6d16
commit e701fa01da
33 changed files with 3033 additions and 337 deletions

View File

@@ -0,0 +1,99 @@
const SNAPSHOT_VERSION = 1
const STORAGE_PREFIX = 'x-financial:assistant-session'
export const ASSISTANT_SESSION_SNAPSHOT_EVENT = 'x-financial-assistant-session-snapshot'
function normalizeSessionType(sessionType) {
return String(sessionType || 'expense').trim() || 'expense'
}
function normalizeUserId(userId) {
return String(userId || 'anonymous').trim() || 'anonymous'
}
export function resolveAssistantSessionSnapshotKey(userId, sessionType = 'expense') {
return `${STORAGE_PREFIX}:${normalizeUserId(userId)}:${normalizeSessionType(sessionType)}`
}
function getStorage() {
if (typeof window === 'undefined' || !window.localStorage) {
return null
}
return window.localStorage
}
function emitSnapshotChange(sessionType) {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new CustomEvent(ASSISTANT_SESSION_SNAPSHOT_EVENT, {
detail: { sessionType: normalizeSessionType(sessionType) }
}))
}
export function readAssistantSessionSnapshot(userId, sessionType = 'expense') {
const storage = getStorage()
if (!storage) {
return null
}
try {
const rawValue = storage.getItem(resolveAssistantSessionSnapshotKey(userId, sessionType))
if (!rawValue) {
return null
}
const parsed = JSON.parse(rawValue)
if (!parsed || parsed.version !== SNAPSHOT_VERSION || !parsed.state) {
return null
}
return parsed
} catch (error) {
console.warn('Failed to read assistant session snapshot:', error)
return null
}
}
export function writeAssistantSessionSnapshot(userId, sessionType = 'expense', state = {}) {
const storage = getStorage()
if (!storage) {
return
}
const normalizedSessionType = normalizeSessionType(sessionType)
const snapshot = {
version: SNAPSHOT_VERSION,
updatedAt: Date.now(),
userId: normalizeUserId(userId),
sessionType: normalizedSessionType,
state
}
try {
storage.setItem(
resolveAssistantSessionSnapshotKey(userId, normalizedSessionType),
JSON.stringify(snapshot)
)
emitSnapshotChange(normalizedSessionType)
} catch (error) {
console.warn('Failed to write assistant session snapshot:', error)
}
}
export function clearAssistantSessionSnapshot(userId, sessionType = 'expense') {
const storage = getStorage()
if (!storage) {
return
}
const normalizedSessionType = normalizeSessionType(sessionType)
try {
storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType))
emitSnapshotChange(normalizedSessionType)
} catch (error) {
console.warn('Failed to clear assistant session snapshot:', error)
}
}
export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') {
return Boolean(readAssistantSessionSnapshot(userId, sessionType)?.state)
}

View File

@@ -41,6 +41,10 @@ const FLOW_INTENT_KEYWORDS = {
explain: ['为什么', '依据', '规则', '怎么']
}
const EXPLICIT_EXPENSE_INTENT_PATTERN = /报销|报账|费用|发票|票据|单据|垫付|报销单|冲销|借款/
const NON_EXPENSE_INTENT_PATTERN = /怎么部署|如何部署|部署步骤|技术方案|排期|任务|工单|需求|代码|脚本|服务器配置|运维|实施计划|项目计划|会议纪要|周报|日报|总结/
const BUSINESS_ACTIVITY_PATTERN = /去|到|赴|前往|支撑|支持|部署|实施|驻场|出差|拜访|客户|项目|现场|电力|银行|医院|学校|园区|公司|集团|服务器/
function normalizeCompactText(value) {
return String(value || '').trim().replace(/\s+/g, '')
}
@@ -125,11 +129,74 @@ export function inferLocalFlowCandidates(rawText) {
}
}
export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
return false
}
if (String(options.reviewAction || '').trim()) {
return false
}
if (options.hasSelectedExpenseType) {
return false
}
const compact = normalizeCompactText(rawText)
if (!compact) {
return false
}
const hasExpenseIntent = /报销|报账|费用|申请/.test(compact)
if (!hasExpenseIntent) {
return false
}
const candidates = inferLocalFlowCandidates(rawText)
return !candidates.expenseType
}
export function shouldRequestExpenseIntentConfirmation(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
return false
}
if (String(options.reviewAction || '').trim()) {
return false
}
if (options.hasConfirmedExpenseIntent || options.hasSelectedExpenseType) {
return false
}
const compact = normalizeCompactText(rawText)
if (!compact || compact.length < 6) {
return false
}
if (EXPLICIT_EXPENSE_INTENT_PATTERN.test(compact)) {
return false
}
if (NON_EXPENSE_INTENT_PATTERN.test(compact)) {
return false
}
return BUSINESS_ACTIVITY_PATTERN.test(compact)
}
export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_TYPE_EXPENSE, options = {}) {
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'
}
if (shouldRequestExpenseSceneSelection(rawText, { ...options, sessionType })) {
return '初步识别为报销申请,但报销场景尚未明确,需要先由用户选择场景'
}
const compact = normalizeCompactText(rawText)
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>