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

@@ -56,7 +56,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ride_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/
function parseCurrency(value) {
@@ -93,29 +94,59 @@ function resolveLocationSummaryLabel(value) {
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
}
function resolveLocationDisplay(value, expenseType) {
if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) {
return '非必填'
}
return isPlaceholderValue(value) ? '待补充' : value
}
function isRouteDescriptionExpenseType(value) {
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function isHotelDescriptionExpenseType(value) {
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function resolveExpenseDetailHint(expenseType) {
if (isRouteDescriptionExpenseType(expenseType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(expenseType)) {
return '目的地酒店'
}
if (!isLocationRequiredExpenseType(expenseType)) {
return '非必填'
}
return '待补充'
}
function resolveLocationDisplay(value, expenseType) {
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
}
function isSyntheticLocationDisplay(value, expenseType) {
const text = String(value || '').trim()
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
}
function isValidRouteDescription(value) {
const text = String(value || '').trim()
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
}
function resolveExpenseReasonPlaceholder(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'
if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地,例如:广州南-北京南'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店,例如:北京中心酒店'
}
return '输入费用说明'
}
function resolveExpenseReasonHelper(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地' : '业务报销说明'
if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店'
}
return '业务报销说明'
}
function buildFallbackProgressSteps() {
@@ -399,6 +430,46 @@ function buildExpenseDraftIssues(item) {
return issues
}
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||
requestModel?.typeCode === 'travel' ||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
if (!isTravelContext) {
return []
}
const hasUploadedType = (itemType) =>
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
const cards = []
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
})
}
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
})
}
return cards
}
function buildDraftBlockingIssues(request, expenseItems) {
const issues = []
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
@@ -482,7 +553,7 @@ function mapIssueToAdvice(issue) {
return `${labelPrefix}的用途说明。`
}
if (fieldText === '行程说明格式错误') {
return `${labelPrefix}的行程说明,格式应为“始地-目的地”。`
return `${labelPrefix}的行程说明,格式应为“始地-目的地”。`
}
if (fieldText === '缺少地点') {
return `${labelPrefix}的业务地点。`
@@ -987,11 +1058,14 @@ export default {
const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const riskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
})
const riskCards = [
...buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
}),
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
]
return buildAiAdviceViewModel({
completionItems,
@@ -1029,6 +1103,17 @@ export default {
}
}
function populateExpenseEditor(item) {
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function startExpenseEdit(item) {
if (!isEditableRequest.value || actionBusy.value) {
return
@@ -1038,40 +1123,31 @@ export default {
return
}
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function cancelExpenseEdit() {
editingExpenseId.value = ''
populateExpenseEditor(item)
}
function validateExpenseEditor() {
if (!isValidIsoDate(expenseEditor.itemDate)) {
if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
}
if (isPlaceholderValue(expenseEditor.itemType)) {
return '请选择费用项目。'
}
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (
!isPlaceholderValue(expenseEditor.itemReason)
&&
isRouteDescriptionExpenseType(expenseEditor.itemType)
&& !isValidRouteDescription(expenseEditor.itemReason)
) {
return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'
return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
return '请输入大于 0 的费用金额。'
const amountText = String(expenseEditor.itemAmount || '').trim()
if (amountText) {
const amount = Number(amountText)
if (!Number.isFinite(amount) || amount < 0) {
return '请输入不小于 0 的费用金额。'
}
}
return ''
}
@@ -1223,10 +1299,26 @@ export default {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
}
if (recognizedItemDate) {
itemPatch.itemDate = recognizedItemDate
}
if (recognizedItemType) {
itemPatch.itemType = recognizedItemType
}
if (recognizedItemReason) {
itemPatch.itemReason = recognizedItemReason
}
if (recognizedItemLocation) {
itemPatch.itemLocation = recognizedItemLocation
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
@@ -1234,12 +1326,7 @@ export default {
applyLocalExpenseItemPatch(item.id, {
...itemPatch
})
if (editingExpenseId.value === item.id) {
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
expenseEditor.itemAmount = String(recognizedItemAmount)
}
}
populateExpenseEditor({ ...item, ...itemPatch })
emit('request-updated', { claimId: request.value.claimId })
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
@@ -1370,20 +1457,25 @@ export default {
try {
const nextInvoiceId = expenseEditor.invoiceId.trim()
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
await updateExpenseClaimItem(request.value.claimId, item.id, {
item_date: expenseEditor.itemDate,
const amountText = String(expenseEditor.itemAmount || '').trim()
const nextAmount = amountText ? Number(amountText) : 0
const itemPayload = {
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: preservedLocation,
item_amount: Number(expenseEditor.itemAmount),
item_amount: nextAmount,
invoice_id: nextInvoiceId
})
}
if (expenseEditor.itemDate) {
itemPayload.item_date = expenseEditor.itemDate
}
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
applyLocalExpenseItemPatch(item.id, {
itemDate: expenseEditor.itemDate,
itemDate: expenseEditor.itemDate || item.itemDate,
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: preservedLocation,
itemAmount: Number(expenseEditor.itemAmount),
itemAmount: nextAmount,
invoiceId: nextInvoiceId
})
let riskNotice = ''
@@ -1713,7 +1805,6 @@ export default {
triggerExpenseUpload,
uploadedExpenseCount,
uploadingExpenseId,
cancelExpenseEdit,
saveExpenseEdit
}
}