feat: 细化差旅票据费用明细分类并自动计算出差补贴
将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类 型,根据票据字段自动生成行程/事由描述,结合规则引擎自 动计算出差补贴金额,前端适配费用明细编辑和差旅票据审 核交互,补充单元测试覆盖。
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user