2026-05-21 23:53:03 +08:00
|
|
|
|
export const EXPENSE_TYPE_OPTIONS = [
|
|
|
|
|
|
{ value: 'travel', label: '差旅费' },
|
|
|
|
|
|
{ value: 'train_ticket', label: '火车票' },
|
|
|
|
|
|
{ value: 'flight_ticket', label: '机票' },
|
2026-05-22 16:00:19 +08:00
|
|
|
|
{ value: 'ship_ticket', label: '轮船票' },
|
|
|
|
|
|
{ value: 'ferry_ticket', label: '轮船票' },
|
2026-05-21 23:53:03 +08:00
|
|
|
|
{ value: 'hotel_ticket', label: '住宿票' },
|
|
|
|
|
|
{ value: 'ride_ticket', label: '乘车' },
|
2026-05-22 23:47:28 +08:00
|
|
|
|
{ value: 'office', label: '办公用品费' },
|
2026-05-21 23:53:03 +08:00
|
|
|
|
{ value: 'meeting', label: '会务费' },
|
|
|
|
|
|
{ value: 'training', label: '培训费' },
|
|
|
|
|
|
{ value: 'hotel', label: '住宿费' },
|
|
|
|
|
|
{ value: 'transport', label: '交通费' },
|
2026-05-22 23:47:28 +08:00
|
|
|
|
{ value: 'meal', label: '业务招待费' },
|
2026-05-21 23:53:03 +08:00
|
|
|
|
{ value: 'travel_allowance', label: '出差补贴' },
|
|
|
|
|
|
{ value: 'other', label: '其他费用' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-22 23:47:28 +08:00
|
|
|
|
const LEGACY_EXPENSE_TYPE_LABELS = {
|
|
|
|
|
|
entertainment: '业务招待费'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
|
|
|
|
|
'travel',
|
|
|
|
|
|
'meeting',
|
|
|
|
|
|
'entertainment'
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
|
|
|
|
|
export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
|
|
|
|
|
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
|
|
|
|
|
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
|
|
|
|
|
export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
|
|
|
|
|
|
|
|
|
|
|
export function parseCurrency(value) {
|
|
|
|
|
|
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function formatCurrency(value) {
|
|
|
|
|
|
return new Intl.NumberFormat('zh-CN', {
|
|
|
|
|
|
style: 'currency',
|
|
|
|
|
|
currency: 'CNY',
|
|
|
|
|
|
minimumFractionDigits: 0,
|
|
|
|
|
|
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
|
|
|
|
|
}).format(value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function normalizeExpenseType(value) {
|
|
|
|
|
|
return String(value || '').trim() || 'other'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveExpenseTypeLabel(value) {
|
2026-05-22 23:47:28 +08:00
|
|
|
|
const normalized = normalizeExpenseType(value)
|
|
|
|
|
|
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
|
|
|
|
|
|
|| LEGACY_EXPENSE_TYPE_LABELS[normalized]
|
|
|
|
|
|
|| '其他费用'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isLocationRequiredExpenseType(value) {
|
|
|
|
|
|
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveLocationSummaryLabel(value) {
|
|
|
|
|
|
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isRouteDescriptionExpenseType(value) {
|
|
|
|
|
|
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isHotelDescriptionExpenseType(value) {
|
|
|
|
|
|
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveExpenseDetailHint(expenseType) {
|
|
|
|
|
|
if (isRouteDescriptionExpenseType(expenseType)) {
|
|
|
|
|
|
return '起始地-目的地'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isHotelDescriptionExpenseType(expenseType)) {
|
|
|
|
|
|
return '目的地酒店'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!isLocationRequiredExpenseType(expenseType)) {
|
|
|
|
|
|
return '非必填'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '待补充'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveLocationDisplay(value, expenseType) {
|
|
|
|
|
|
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isSyntheticLocationDisplay(value, expenseType) {
|
|
|
|
|
|
const text = String(value || '').trim()
|
|
|
|
|
|
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isValidRouteDescription(value) {
|
|
|
|
|
|
const text = String(value || '').trim()
|
|
|
|
|
|
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveExpenseReasonPlaceholder(itemType) {
|
|
|
|
|
|
if (isRouteDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '起始地-目的地,例如:广州南-北京南'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isHotelDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '目的地酒店,例如:北京中心酒店'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '输入费用说明'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveExpenseReasonHelper(itemType) {
|
|
|
|
|
|
if (isRouteDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '起始地-目的地'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isHotelDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '目的地酒店'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '业务报销说明'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
|
|
|
|
|
|
if (isRouteDescriptionExpenseType(itemType) || isHotelDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return resolveExpenseReasonHelper(itemType)
|
|
|
|
|
|
}
|
|
|
|
|
|
return resolveLocationDisplay(itemLocation, itemType)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
export function buildFallbackProgressSteps() {
|
|
|
|
|
|
return [
|
|
|
|
|
|
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
|
|
|
|
|
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
|
|
|
|
|
{ index: 3, label: 'AI预审', time: '待处理' },
|
|
|
|
|
|
{ index: 4, label: '直属领导审批', time: '待处理' },
|
|
|
|
|
|
{ index: 5, label: '财务审批', time: '待处理' },
|
|
|
|
|
|
{ index: 6, label: '归档入账', time: '待处理' }
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildFallbackExpenseItems(request) {
|
|
|
|
|
|
return [
|
|
|
|
|
|
buildExpenseItemViewModel({
|
|
|
|
|
|
id: 'fallback-1',
|
|
|
|
|
|
itemDate: '',
|
|
|
|
|
|
itemType: request.typeCode || 'other',
|
|
|
|
|
|
itemReason: request.reason,
|
|
|
|
|
|
itemLocation: request.sceneTarget,
|
|
|
|
|
|
itemAmount: parseCurrency(request.amountDisplay),
|
|
|
|
|
|
invoiceId: '',
|
|
|
|
|
|
time: '待补充',
|
|
|
|
|
|
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
|
|
|
|
|
|
name: request.typeLabel,
|
|
|
|
|
|
category: request.typeLabel,
|
|
|
|
|
|
desc: request.reason,
|
|
|
|
|
|
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
|
|
|
|
|
|
amount: request.amountDisplay,
|
|
|
|
|
|
status: '待补充',
|
|
|
|
|
|
tone: 'bad',
|
|
|
|
|
|
attachmentStatus: '待上传',
|
|
|
|
|
|
attachmentHint: '请在此单据中继续补充附件',
|
|
|
|
|
|
attachmentTone: 'missing',
|
|
|
|
|
|
attachments: [],
|
|
|
|
|
|
riskLabel: '待补材料',
|
|
|
|
|
|
riskText: request.riskSummary,
|
|
|
|
|
|
riskTone: 'medium'
|
|
|
|
|
|
}, 0, request)
|
|
|
|
|
|
]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isPlaceholderValue(value) {
|
|
|
|
|
|
const text = String(value || '').trim()
|
|
|
|
|
|
if (!text) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function normalizeDetailNoteDraftValue(value) {
|
|
|
|
|
|
const text = String(value || '').trim()
|
|
|
|
|
|
return isPlaceholderValue(text) ? '' : text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function isValidIsoDate(value) {
|
|
|
|
|
|
const normalized = String(value || '').trim()
|
|
|
|
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const [yearText, monthText, dayText] = normalized.split('-')
|
|
|
|
|
|
const year = Number(yearText)
|
|
|
|
|
|
const month = Number(monthText)
|
|
|
|
|
|
const day = Number(dayText)
|
|
|
|
|
|
|
|
|
|
|
|
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const candidate = new Date(Date.UTC(year, month - 1, day))
|
|
|
|
|
|
return (
|
|
|
|
|
|
candidate.getUTCFullYear() === year &&
|
|
|
|
|
|
candidate.getUTCMonth() === month - 1 &&
|
|
|
|
|
|
candidate.getUTCDate() === day
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function normalizeIsoDateValue(value) {
|
|
|
|
|
|
const normalized = String(value || '').trim()
|
|
|
|
|
|
if (isValidIsoDate(normalized)) {
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/)
|
|
|
|
|
|
if (match && isValidIsoDate(match[1])) {
|
|
|
|
|
|
return match[1]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const candidate = value instanceof Date ? value : new Date(normalized)
|
|
|
|
|
|
if (Number.isNaN(candidate.getTime())) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const year = candidate.getFullYear()
|
|
|
|
|
|
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
|
|
|
|
|
const day = String(candidate.getDate()).padStart(2, '0')
|
|
|
|
|
|
return `${year}-${month}-${day}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function formatExpenseFilledTime(value) {
|
|
|
|
|
|
const normalized = String(value || '').trim()
|
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const candidate = value instanceof Date ? value : new Date(normalized)
|
|
|
|
|
|
if (Number.isNaN(candidate.getTime())) {
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const year = candidate.getFullYear()
|
|
|
|
|
|
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
|
|
|
|
|
const day = String(candidate.getDate()).padStart(2, '0')
|
|
|
|
|
|
const hours = String(candidate.getHours()).padStart(2, '0')
|
|
|
|
|
|
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
|
|
|
|
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveExpenseUploadHint(value) {
|
|
|
|
|
|
const normalized = String(value || '').trim()
|
|
|
|
|
|
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function extractAttachmentDisplayName(value) {
|
|
|
|
|
|
const normalized = String(value || '').trim()
|
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return normalized.split('/').filter(Boolean).pop() || normalized
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function resolveExpenseItemViewId(source, index, requestModel) {
|
|
|
|
|
|
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export 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' ? '出行时间' : '业务发生时间'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export 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)
|
|
|
|
|
|
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
|
|
|
|
|
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
|
|
|
|
|
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
|
|
|
|
|
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
|
|
|
|
|
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
|
|
|
|
|
const riskText = String(source?.riskText || '').trim()
|
|
|
|
|
|
const filledAt = formatExpenseFilledTime(
|
|
|
|
|
|
source?.filledAt
|
|
|
|
|
|
|| source?.filled_at
|
|
|
|
|
|
|| source?.createdAt
|
|
|
|
|
|
|| source?.created_at
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
id,
|
|
|
|
|
|
itemDate,
|
|
|
|
|
|
itemType,
|
|
|
|
|
|
itemReason,
|
|
|
|
|
|
itemLocation,
|
|
|
|
|
|
itemAmount,
|
|
|
|
|
|
invoiceId,
|
|
|
|
|
|
isSystemGenerated,
|
|
|
|
|
|
time: itemDate || '待补充',
|
|
|
|
|
|
filledAt: filledAt || '待同步',
|
|
|
|
|
|
dayLabel: resolveExpenseTimeLabel({
|
|
|
|
|
|
id,
|
|
|
|
|
|
itemType,
|
|
|
|
|
|
isSystemGenerated,
|
|
|
|
|
|
requestModel,
|
|
|
|
|
|
travelTimeLabelMap
|
|
|
|
|
|
}),
|
|
|
|
|
|
name: resolveExpenseTypeLabel(itemType),
|
|
|
|
|
|
category: resolveExpenseTypeLabel(itemType),
|
|
|
|
|
|
desc: itemReason || '待补充',
|
2026-05-22 16:00:19 +08:00
|
|
|
|
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
|
2026-05-21 23:53:03 +08:00
|
|
|
|
amount: amountDisplay,
|
|
|
|
|
|
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,
|
|
|
|
|
|
riskTone: String(source?.riskTone || '').trim() || 'low'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function rebuildExpenseItems(items, 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildExpenseDraftIssues(item) {
|
|
|
|
|
|
const issues = []
|
|
|
|
|
|
if (item.isSystemGenerated) {
|
|
|
|
|
|
return issues
|
|
|
|
|
|
}
|
|
|
|
|
|
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
|
|
|
|
|
|
|
|
|
|
|
if (!isValidIsoDate(item.itemDate)) {
|
|
|
|
|
|
issues.push('缺少日期')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(item.itemType)) {
|
|
|
|
|
|
issues.push('缺少费用项目')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(item.itemReason)) {
|
|
|
|
|
|
issues.push('缺少说明')
|
|
|
|
|
|
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
|
|
|
|
|
issues.push('行程说明格式错误')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
|
|
|
|
|
issues.push('缺少地点')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
|
|
|
|
|
issues.push('缺少金额')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(item.invoiceId)) {
|
|
|
|
|
|
issues.push('缺少票据标识')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return issues
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function buildDraftBlockingIssues(request, expenseItems) {
|
|
|
|
|
|
const issues = []
|
|
|
|
|
|
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
|
|
|
|
|
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
|
|
|
|
|
|
const itemAmountTotal = normalizedItems.reduce((sum, item) => {
|
|
|
|
|
|
const amount = Number(item?.itemAmount || 0)
|
|
|
|
|
|
return Number.isFinite(amount) && amount > 0 ? sum + amount : sum
|
|
|
|
|
|
}, 0)
|
|
|
|
|
|
const hasValidItemDate = normalizedItems.some((item) => isValidIsoDate(item?.itemDate))
|
|
|
|
|
|
const hasValidItemType = normalizedItems.some((item) => !isPlaceholderValue(item?.itemType))
|
|
|
|
|
|
const hasValidItemReason = normalizedItems.some((item) => !isPlaceholderValue(item?.itemReason))
|
|
|
|
|
|
const hasValidItemLocation = normalizedItems.some((item) => !isPlaceholderValue(item?.itemLocation))
|
|
|
|
|
|
|
|
|
|
|
|
if (isPlaceholderValue(request.profileName)) {
|
|
|
|
|
|
issues.push('申请人未完善')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(request.typeLabel) && !hasValidItemType) {
|
|
|
|
|
|
issues.push('报销类型未完善')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(request.reason) && !hasValidItemReason) {
|
|
|
|
|
|
issues.push('报销事由未完善')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (locationRequired && isPlaceholderValue(request.location) && !hasValidItemLocation) {
|
|
|
|
|
|
issues.push('业务地点未完善')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(request.occurredDisplay) && !hasValidItemDate) {
|
|
|
|
|
|
issues.push('发生时间未完善')
|
|
|
|
|
|
}
|
|
|
|
|
|
if ((!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) && itemAmountTotal <= 0) {
|
|
|
|
|
|
issues.push('报销金额未完善')
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!normalizedItems.length) {
|
|
|
|
|
|
issues.push('费用明细不能为空')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
normalizedItems.forEach((item, index) => {
|
|
|
|
|
|
buildExpenseDraftIssues(item).forEach((issue) => {
|
|
|
|
|
|
issues.push(`费用明细第 ${index + 1} 条${issue}`)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return [...new Set(issues)]
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function mapIssueToAdvice(issue) {
|
|
|
|
|
|
const text = String(issue || '').trim()
|
|
|
|
|
|
if (!text) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (text === '费用明细不能为空') {
|
|
|
|
|
|
return '先新增至少 1 条费用明细,再补充金额、用途和附件。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '申请人未完善') {
|
|
|
|
|
|
return '补充申请人信息,确保审批单据归属明确。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '所属部门未完善') {
|
|
|
|
|
|
return '补充所属部门,便于财务和审批人识别成本归属。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '报销类型未完善') {
|
|
|
|
|
|
return '选择报销类型,明确本次费用归类。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '报销事由未完善') {
|
|
|
|
|
|
return '补充报销事由,说明本次费用用途。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '业务地点未完善') {
|
|
|
|
|
|
return '补充业务地点,方便审核业务发生场景。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '发生时间未完善') {
|
|
|
|
|
|
return '补充费用发生时间,确保单据时间完整。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '报销金额未完善') {
|
|
|
|
|
|
return '补充报销金额,并与费用明细金额保持一致。'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
|
|
|
|
|
|
if (!itemMatch) {
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const [, indexText, fieldText] = itemMatch
|
|
|
|
|
|
const labelPrefix = `完善第 ${indexText} 条费用明细`
|
|
|
|
|
|
if (fieldText === '缺少日期') {
|
|
|
|
|
|
return `${labelPrefix}的发生日期。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少费用项目') {
|
|
|
|
|
|
return `${labelPrefix}的费用项目。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少说明') {
|
|
|
|
|
|
return `${labelPrefix}的用途说明。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '行程说明格式错误') {
|
|
|
|
|
|
return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少地点') {
|
|
|
|
|
|
return `${labelPrefix}的业务地点。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少金额') {
|
|
|
|
|
|
return `${labelPrefix}的金额。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少票据标识') {
|
|
|
|
|
|
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `${labelPrefix}。`
|
|
|
|
|
|
}
|