feat: 细化差旅票据费用明细分类并自动计算出差补贴
将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类 型,根据票据字段自动生成行程/事由描述,结合规则引擎自 动计算出差补贴金额,前端适配费用明细编辑和差旅票据审 核交互,补充单元测试覆盖。
This commit is contained in:
@@ -4,6 +4,11 @@ import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
train_ticket: '火车票',
|
||||
flight_ticket: '机票',
|
||||
hotel_ticket: '住宿票',
|
||||
ride_ticket: '乘车',
|
||||
travel_allowance: '出差补贴',
|
||||
entertainment: '业务招待费',
|
||||
office: '办公费',
|
||||
meeting: '会务费',
|
||||
@@ -16,10 +21,17 @@ const EXPENSE_TYPE_LABELS = {
|
||||
|
||||
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'])
|
||||
|
||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||
'创建单据',
|
||||
'待提交',
|
||||
@@ -123,6 +135,57 @@ function resolveLocationDisplay(location, typeCode) {
|
||||
return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
|
||||
}
|
||||
|
||||
function resolveExpenseItemViewId(item, index, claim) {
|
||||
return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, claim) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
|
||||
return {
|
||||
id: resolveExpenseItemViewId(item, index, claim),
|
||||
index,
|
||||
itemType,
|
||||
itemDate: formatDate(item?.item_date),
|
||||
isSystemGenerated: Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(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()
|
||||
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, claim, travelTimeLabelMap }) {
|
||||
if (isSystemGenerated) {
|
||||
return '系统自动计算'
|
||||
}
|
||||
if (travelTimeLabelMap?.has(id)) {
|
||||
return travelTimeLabelMap.get(id)
|
||||
}
|
||||
if (itemType === 'ride_ticket') {
|
||||
return '乘车时间'
|
||||
}
|
||||
if (itemType === 'hotel_ticket') {
|
||||
return '住宿时间'
|
||||
}
|
||||
return claim?.expense_type === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
function resolveAttachmentDisplayName(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
@@ -498,11 +561,20 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
return []
|
||||
}
|
||||
|
||||
return claim.items.map((item, index) => {
|
||||
const sortedItems = [...claim.items].sort((left, right) => {
|
||||
const leftType = normalizeExpenseType(left?.item_type)
|
||||
const rightType = normalizeExpenseType(right?.item_type)
|
||||
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
|
||||
})
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
|
||||
|
||||
return sortedItems.map((item, index) => {
|
||||
const invoiceId = String(item?.invoice_id || '').trim()
|
||||
const attachmentName = resolveAttachmentDisplayName(invoiceId)
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
|
||||
const isSystemGenerated = Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
const id = resolveExpenseItemViewId(item, index, claim)
|
||||
const itemTypeLabel = resolveTypeLabel(itemType)
|
||||
const itemLocation = String(item?.item_location || '').trim()
|
||||
const itemReason = String(item?.item_reason || '').trim()
|
||||
@@ -510,7 +582,7 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
|
||||
|
||||
return {
|
||||
id: String(item?.id || `${claim?.id || 'claim'}-item-${index}`),
|
||||
id,
|
||||
time: formatDate(item?.item_date) || '待补充',
|
||||
itemDate: formatDate(item?.item_date) || '',
|
||||
filledAt: formatDateTime(item?.created_at) || '待同步',
|
||||
@@ -519,17 +591,24 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
itemLocation,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
dayLabel: claim?.expense_type === 'travel' ? `第 ${index + 1} 项` : '业务发生项',
|
||||
isSystemGenerated,
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
claim,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: itemTypeLabel,
|
||||
category: itemTypeLabel,
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
amount: itemAmountDisplay,
|
||||
status: attachments.length ? '已识别' : '待补充',
|
||||
tone: attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
|
||||
attachmentTone: attachments.length ? 'ok' : 'missing',
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: riskSummary === '无' ? '无' : '待关注',
|
||||
riskText: riskSummary === '无' ? '' : riskSummary,
|
||||
|
||||
Reference in New Issue
Block a user