feat: 细化差旅票据费用明细分类并自动计算出差补贴

将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类
型,根据票据字段自动生成行程/事由描述,结合规则引擎自
动计算出差补贴金额,前端适配费用明细编辑和差旅票据审
核交互,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 10:57:06 +08:00
parent 8f65661809
commit b183b0bd5e
26 changed files with 2588 additions and 362 deletions

View File

@@ -1497,6 +1497,20 @@ function resolveReviewMissingSlotCards(reviewPayload) {
: []
}
function resolveReviewExtraMissingLabels(reviewPayload) {
const labels = Array.isArray(reviewPayload?.missing_slots)
? reviewPayload.missing_slots.map((item) => String(item || '').trim()).filter(Boolean)
: []
if (!labels.length) return []
const slotLabels = new Set(
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : [])
.map((item) => String(item?.label || item?.key || '').trim())
.filter(Boolean)
)
return labels.filter((label) => !slotLabels.has(label))
}
function resolveReviewRiskBriefs(reviewPayload) {
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => {
@@ -1762,7 +1776,7 @@ function buildExpenseQueryHint(queryPayload) {
}
function countReviewPendingItems(reviewPayload) {
return resolveReviewMissingSlotCards(reviewPayload).length
return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length
}
function countReviewRiskItems(reviewPayload) {
@@ -1825,12 +1839,12 @@ function shouldOpenReviewDisclosure(reviewPayload) {
}
function buildReviewTodoSectionTitle(reviewPayload) {
return resolveReviewMissingSlotCards(reviewPayload).length ? '待补充内容' : '已识别信息'
return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息'
}
function buildReviewTodoSectionMeta(reviewPayload) {
const count = buildReviewTodoItems(reviewPayload).length
if (resolveReviewMissingSlotCards(reviewPayload).length) {
if (countReviewPendingItems(reviewPayload)) {
return count ? `${count}` : '待确认'
}
return count ? `${count}` : '已齐全'
@@ -1864,6 +1878,17 @@ function buildReviewAlertChips(reviewPayload) {
})
}
if (chips.length < 3) {
for (const label of resolveReviewExtraMissingLabels(reviewPayload)) {
chips.push({
key: label,
label,
tone: 'warning'
})
if (chips.length >= 3) break
}
}
if (chips.length < 3) {
for (const risk of resolveReviewRiskBriefs(reviewPayload)) {
if (chips.some((item) => item.label === risk.title)) continue
@@ -1889,8 +1914,10 @@ function buildReviewAlertChips(reviewPayload) {
function buildReviewTodoItems(reviewPayload) {
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
if (missingItems.length) {
return missingItems.map((item) => {
const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload)
if (missingItems.length || extraMissingLabels.length) {
return [
...missingItems.map((item) => {
const config = REVIEW_SLOT_CONFIG[item.key] || {}
return {
key: item.key,
@@ -1900,7 +1927,18 @@ function buildReviewTodoItems(reviewPayload) {
status: config.status || '待补充',
tone: 'warning'
}
})
}),
...extraMissingLabels.map((label, index) => ({
key: `extra-missing-${index}-${label}`,
icon: label.includes('酒店') || label.includes('住宿') ? 'mdi mdi-bed-outline' : 'mdi mdi-file-alert-outline',
title: label,
hint: label.includes('必须')
? '该票据属于当前差旅提交的必备材料,补齐后才能继续下一步。'
: '可以继续补充该材料;如暂时没有,也可以按当前信息处理。',
status: label.includes('必须') ? '必须补齐' : '可选补充',
tone: 'warning'
}))
]
}
return resolveReviewRecognizedSlotCards(reviewPayload)
@@ -2571,8 +2609,18 @@ function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
return actions
}
const syncedActions = actions.filter((item) => String(item?.action_type || '').trim() !== 'next_step')
if (!syncedActions.some((item) => String(item?.action_type || '').trim() === 'save_draft')) {
syncedActions.push({
label: '保存为草稿',
action_type: 'save_draft',
description: '先暂存当前已识别信息,稍后仍可继续补充或提交。',
emphasis: 'secondary'
})
}
return [
...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())),
...syncedActions,
{
label: '继续下一步',
action_type: 'next_step',
@@ -2607,12 +2655,17 @@ function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmpt
const missingSlots = nextSlotCards
.filter((slot) => slot.required && slot.status === 'missing')
.map((slot) => slot.label || slot.key)
const canProceed = missingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
const extraMissingSlots = resolveReviewExtraMissingLabels({
...reviewPayload,
slot_cards: nextSlotCards
})
const allMissingSlots = [...missingSlots, ...extraMissingSlots]
const canProceed = allMissingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
return {
...reviewPayload,
can_proceed: canProceed,
missing_slots: missingSlots,
missing_slots: allMissingSlots,
slot_cards: nextSlotCards,
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
}
@@ -2821,22 +2874,24 @@ function buildReviewDocumentSummaries(reviewPayload) {
}
function buildReviewDecisionHint(reviewPayload) {
const missingSlots = resolveReviewMissingSlotCards(reviewPayload)
const pendingCount = countReviewPendingItems(reviewPayload)
const riskBriefs = resolveReviewRiskBriefs(reviewPayload)
if (reviewPayload?.can_proceed) {
if (shouldShowReviewUploadButton(reviewPayload)) {
return '必需信息已整理好;如还有非必需票据可以继续上传,也可以直接进入下一步或保存草稿。'
}
return riskBriefs.length
? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。`
: '我已经把信息整理好了。你确认无误后,可以直接进入下一步。'
}
if (missingSlots.length) {
return `我先完成了当前这轮识别,还差 ${missingSlots.length} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。`
if (pendingCount) {
return `我先完成了当前这轮识别,还差 ${pendingCount} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。`
}
return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。'
}
function buildReviewMissingHint(reviewPayload) {
const missingSlots = resolveReviewMissingSlotCards(reviewPayload)
if (!missingSlots.length) {
if (!countReviewPendingItems(reviewPayload)) {
return ''
}
if (reviewPayload?.can_proceed) {
@@ -2860,8 +2915,19 @@ function buildReviewActionHint(reviewPayload) {
return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。'
}
function shouldShowReviewUploadButton(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
if (!documents.length) return true
if (countReviewPendingItems(reviewPayload)) return true
return resolveReviewRiskBriefs(reviewPayload).some((brief) => {
const text = `${brief?.title || ''} ${brief?.content || ''} ${brief?.suggestion || ''}`
return /差旅票据待补充|待上传|可继续上传|可继续提供/.test(text)
})
}
function buildReviewStatusTag(reviewPayload) {
const missingCount = resolveReviewMissingSlotCards(reviewPayload).length
const missingCount = countReviewPendingItems(reviewPayload)
if (reviewPayload?.can_proceed) {
return '可继续处理'
}
@@ -5607,6 +5673,7 @@ export default {
buildReviewTodoSectionMeta,
buildReviewAlertChips,
buildReviewTodoItems,
shouldShowReviewUploadButton,
resolveReviewSubmitActions,
resolveReviewPrimaryAction,
resolveReviewEditAction,

View File

@@ -15,6 +15,7 @@ import {
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
updateExpenseClaim,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import {
@@ -32,6 +33,10 @@ import {
const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'train_ticket', label: '火车票' },
{ value: 'flight_ticket', label: '机票' },
{ value: 'hotel_ticket', label: '住宿票' },
{ value: 'ride_ticket', label: '乘车' },
{ value: 'entertainment', label: '业务招待费' },
{ value: 'office', label: '办公费' },
{ value: 'meeting', label: '会务费' },
@@ -39,15 +44,23 @@ const EXPENSE_TYPE_OPTIONS = [
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '餐费' },
{ value: 'travel_allowance', label: '出差补贴' },
{ value: 'other', label: '其他费用' }
]
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'train_ticket',
'flight_ticket',
'hotel_ticket',
'ride_ticket',
'meeting',
'entertainment'
])
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
@@ -69,6 +82,11 @@ function resolveExpenseTypeLabel(value) {
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
}
function isSystemGeneratedExpenseItemSource(source) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
}
function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
@@ -135,6 +153,11 @@ function isPlaceholderValue(value) {
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function normalizeDetailNoteDraftValue(value) {
const text = String(value || '').trim()
return isPlaceholderValue(text) ? '' : text
}
function isValidIsoDate(value) {
const normalized = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
@@ -213,8 +236,65 @@ function extractAttachmentDisplayName(value) {
return normalized.split('/').filter(Boolean).pop() || normalized
}
function buildExpenseItemViewModel(source, index, requestModel) {
function resolveExpenseItemViewId(source, index, requestModel) {
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
}
function buildTravelTimeLabelMap(items, requestModel) {
const travelItems = items
.map((item, index) => {
const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other')
return {
id: resolveExpenseItemViewId(item, index, requestModel),
index,
itemType,
itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date),
isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, itemType })
}
})
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
.sort((left, right) => {
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
return dateCompare || left.index - right.index
})
const labels = new Map()
if (!travelItems.length) {
return labels
}
travelItems.forEach((item, index) => {
if (index === 0) {
labels.set(item.id, '出发时间')
} else if (index === travelItems.length - 1) {
labels.set(item.id, '返回时间')
} else {
labels.set(item.id, '中转时间')
}
})
return labels
}
function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel, travelTimeLabelMap }) {
if (isSystemGenerated) {
return '系统自动计算'
}
if (travelTimeLabelMap?.has(id)) {
return travelTimeLabelMap.get(id)
}
if (itemType === 'ride_ticket') {
return '乘车时间'
}
if (itemType === 'hotel_ticket') {
return '住宿时间'
}
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
}
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
const id = resolveExpenseItemViewId(source, index, requestModel)
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
@@ -232,26 +312,33 @@ function buildExpenseItemViewModel(source, index, requestModel) {
)
return {
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
id,
itemDate,
itemType,
itemReason,
itemLocation,
itemAmount,
invoiceId,
isSystemGenerated,
time: itemDate || '待补充',
filledAt: filledAt || '待同步',
dayLabel: requestModel?.detailVariant === 'travel' ? `${index + 1}` : '业务发生项',
dayLabel: resolveExpenseTimeLabel({
id,
itemType,
isSystemGenerated,
requestModel,
travelTimeLabelMap
}),
name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType),
desc: itemReason || '待补充',
detail: resolveLocationDisplay(itemLocation, itemType),
amount: amountDisplay,
status: attachments.length ? '已识别' : '待补充',
tone: attachments.length ? 'ok' : 'bad',
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
attachmentTone: attachments.length ? 'ok' : 'missing',
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
attachments,
riskLabel: String(source?.riskLabel || '').trim() || '无',
riskText,
@@ -260,11 +347,17 @@ function buildExpenseItemViewModel(source, index, requestModel) {
}
function rebuildExpenseItems(items, requestModel) {
return items.map((item, index) => buildExpenseItemViewModel(item, index, requestModel))
const sortedItems = [...items]
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
}
function buildExpenseDraftIssues(item) {
const issues = []
if (item.isSystemGenerated) {
return issues
}
const locationRequired = isLocationRequiredExpenseType(item.itemType)
if (!isValidIsoDate(item.itemDate)) {
@@ -441,6 +534,8 @@ export default {
itemAmount: '',
invoiceId: ''
})
const detailNoteEditor = ref('')
const savingDetailNote = ref(false)
const request = computed(() => {
const normalized = normalizeRequestForUi(props.request)
@@ -654,7 +749,7 @@ export default {
}
const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
return formatCurrency(total)
})
@@ -662,10 +757,21 @@ export default {
const expenseTableColumnCount = computed(
() => 6 + (isEditableRequest.value ? 1 : 0)
)
const detailNote = computed(
() =>
request.value.note
|| '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。'
const canEditDetailNote = computed(() => isDraftRequest.value)
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
const detailNote = computed(() => {
if (detailNoteSource.value) {
return detailNoteSource.value
}
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
})
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
watch(
() => [request.value.claimId, detailNoteSource.value],
([, nextNote]) => {
detailNoteEditor.value = nextNote
},
{ immediate: true }
)
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
@@ -873,10 +979,44 @@ export default {
})
})
function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value
}
async function saveDetailNote() {
if (!canEditDetailNote.value || savingDetailNote.value) {
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法保存附加说明。')
return
}
if (!detailNoteDirty.value) {
return
}
savingDetailNote.value = true
try {
await updateExpenseClaim(request.value.claimId, {
reason: detailNoteEditor.value.trim()
})
toast('附加说明已保存。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '附加说明保存失败,请稍后重试。')
} finally {
savingDetailNote.value = false
}
}
function startExpenseEdit(item) {
if (!isEditableRequest.value || actionBusy.value) {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行不能手动编辑。')
return
}
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
@@ -954,6 +1094,11 @@ export default {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行无需上传附件。')
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
return
@@ -1036,6 +1181,10 @@ export default {
if (!item || !file) {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行无需上传附件。')
return
}
if (item?.invoiceId) {
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
@@ -1138,6 +1287,10 @@ export default {
if (!request.value.claimId || !item?.id || actionBusy.value) {
return
}
if (item?.isSystemGenerated) {
toast('系统自动计算的补贴行不能删除。')
return
}
deletingExpenseId.value = item.id
try {
@@ -1468,18 +1621,21 @@ export default {
confirmReturnRequest,
currentAttachmentPreviewInsight,
currentAttachmentPreviewRiskCards,
currentProgressRingMotion,
deleteActionLabel,
deleteBusy,
deleteDialogDescription,
deleteDialogOpen,
deleteDialogTitle,
deletingAttachmentId,
deletingExpenseId,
detailNote,
draftBlockingIssues,
editingExpenseId,
creatingExpense,
currentProgressRingMotion,
canEditDetailNote,
deleteActionLabel,
deleteBusy,
deleteDialogDescription,
deleteDialogOpen,
deleteDialogTitle,
deletingAttachmentId,
deletingExpenseId,
detailNote,
detailNoteDirty,
detailNoteEditor,
draftBlockingIssues,
editingExpenseId,
creatingExpense,
expenseEditor,
expenseItems,
expenseTableColumnCount,
@@ -1502,18 +1658,21 @@ export default {
goToPreviousAttachmentPreview,
profile,
progressSteps,
request,
leaderOpinion,
removeExpenseAttachment,
removeExpenseItem,
resolveAttachmentDisplayName,
resolveAttachmentPreviewTitle,
resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
request,
leaderOpinion,
removeExpenseAttachment,
removeExpenseItem,
resetDetailNote,
resolveAttachmentDisplayName,
resolveAttachmentPreviewTitle,
resolveAttachmentRecognition,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,
returnDialogOpen,
savingExpenseId,
returnDialogOpen,
saveDetailNote,
savingDetailNote,
savingExpenseId,
showLeaderApprovalPanel,
showExpenseRisk,
startExpenseEdit,

View File

@@ -265,19 +265,44 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
const items = [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
]
return {
tone: 'ready',
badge: '可直接提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items: [
'点击右下角“提交审批”进入流程。',
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
],
riskCards: []
items,
riskCards: [],
sections: [
{
kind: 'completion',
title: '建议补充字段',
items
}
]
}
}
const sections = []
if (normalizedCompletionItems.length) {
sections.push({
kind: 'completion',
title: '建议补充字段',
items: normalizedCompletionItems
})
}
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
title: '已知存在风险',
items: normalizedRiskCards
})
}
return {
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对',
@@ -285,6 +310,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
: '建议先补齐必填信息,完成后即可提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards
riskCards: normalizedRiskCards,
sections
}
}