Files
X-Financial/web/src/views/scripts/travelRequestDetailExpenseModel.js
caoxiaozhu 0fac8b615f feat(web): 优化差旅详情、风险建议卡片与文档中心交互
- 拆分阶段风险建议卡片样式到独立文件
- 完善差旅申请审批对话框与详情视图交互
- 调整文档中心列表共享样式与状态筛选
- 同步应用外壳、视图初始化与系统状态 composables
2026-06-17 14:39:12 +08:00

770 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const EXPENSE_TYPE_OPTIONS = [
{ value: 'travel', label: '差旅费' },
{ value: 'train_ticket', label: '火车票' },
{ value: 'flight_ticket', label: '机票' },
{ value: 'ship_ticket', label: '轮船票' },
{ value: 'ferry_ticket', label: '轮船票' },
{ value: 'hotel_ticket', label: '住宿票' },
{ value: 'ride_ticket', label: '乘车' },
{ value: 'office', label: '办公用品费' },
{ value: 'meeting', label: '会务费' },
{ value: 'training', label: '培训费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'travel_allowance', label: '出差补贴' },
{ value: 'other', label: '其他费用' }
]
const LEGACY_EXPENSE_TYPE_LABELS = {
entertainment: '业务招待费'
}
export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'meeting',
'entertainment'
])
export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
export const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set(['ride_ticket', '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 const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
export function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
}
function parseOptionalCurrency(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null
}
const normalized = String(value).replace(/[^\d.]/g, '')
if (!normalized) {
return null
}
const amount = Number.parseFloat(normalized)
return Number.isFinite(amount) && amount >= 0 ? amount : null
}
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 isApplicationDocumentRequest(request) {
const documentType = String(
request?.documentTypeCode
|| request?.document_type_code
|| request?.documentType
|| request?.document_type
|| ''
).trim()
const claimNo = String(
request?.claimNo
|| request?.claim_no
|| request?.documentNo
|| request?.id
|| ''
).trim().toUpperCase()
const typeCode = normalizeExpenseType(request?.typeCode || request?.expense_type)
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')
)
}
export function resolveExpenseTypeLabel(value) {
const normalized = normalizeExpenseType(value)
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
|| LEGACY_EXPENSE_TYPE_LABELS[normalized]
|| '其他费用'
}
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 isAttachmentRequiredExpenseItem(source) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
return !isSystemGeneratedExpenseItemSource({ ...source, itemType }) && !OPTIONAL_ATTACHMENT_EXPENSE_TYPES.has(itemType)
}
export function hasUploadedReceiptReference(source) {
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
if (!isPlaceholderValue(invoiceId)) {
return true
}
return Array.isArray(source?.attachments) && source.attachments.some((item) => !isPlaceholderValue(item))
}
export function isIgnorableExpenseDraftPlaceholder(item) {
if (!item || isSystemGeneratedExpenseItemSource(item) || hasUploadedReceiptReference(item)) {
return false
}
const amount = Number(item?.itemAmount ?? item?.item_amount ?? 0)
const missingAmount = !Number.isFinite(amount) || amount <= 0
const missingReason = isPlaceholderValue(item?.itemReason ?? item?.item_reason)
const missingLocation = isPlaceholderValue(item?.itemLocation ?? item?.item_location)
return missingAmount && missingReason && missingLocation
}
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 '业务报销说明'
}
export function resolveExpenseDescriptionDetail(itemType, itemLocation) {
if (isRouteDescriptionExpenseType(itemType) || isHotelDescriptionExpenseType(itemType)) {
return resolveExpenseReasonHelper(itemType)
}
return resolveLocationDisplay(itemLocation, itemType)
}
export function buildFallbackProgressSteps(requestModel = {}) {
const node = String(requestModel?.node || requestModel?.workflowNode || requestModel?.approvalStage || '').trim()
const approvalKey = String(requestModel?.approvalKey || '').trim()
const pendingPayment = approvalKey === 'pending_payment' || /待付款/.test(node)
const paid = /已付款/.test(node)
const completed = approvalKey === 'completed' || paid || /审批完成|申请完成|已完成/.test(node)
const archived = /申请归档|已归档/.test(node)
const hasRelatedApplication = Boolean(requestModel?.relatedApplication?.claimNo)
if (isApplicationDocumentRequest(requestModel)) {
const inLeaderApproval = approvalKey === 'in_progress' || /直属领导|领导审批|审批中/.test(node)
return [
{
index: 1,
label: '创建申请',
time: completed || inLeaderApproval ? '已完成' : '进行中',
done: completed || inLeaderApproval,
active: true,
current: !(completed || inLeaderApproval)
},
{
index: 2,
label: '直属领导审批',
time: completed ? '已完成' : inLeaderApproval ? '进行中' : '待处理',
done: completed,
active: completed || inLeaderApproval,
current: !completed && inLeaderApproval
},
{
index: 3,
label: '关联单据状态',
time: hasRelatedApplication ? '已关联' : completed ? '未关联' : '待处理',
done: archived,
active: completed || archived,
current: completed && !archived
},
{
index: 4,
label: '已归档',
time: archived ? '已完成' : '待处理',
done: archived,
active: archived,
current: archived
}
]
}
return [
{ index: 1, label: '关联单据', time: hasRelatedApplication ? '已关联' : '待核对', done: hasRelatedApplication, active: true, current: !hasRelatedApplication },
{ index: 2, label: '待提交', time: hasRelatedApplication ? '进行中' : '待处理', active: hasRelatedApplication, current: hasRelatedApplication },
{ index: 3, label: '直属领导审批', time: '待处理' },
{ index: 4, label: '财务审批', time: '待处理' },
{ index: 5, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
{ index: 6, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
{ index: 7, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
]
}
export function buildFallbackExpenseItems(request) {
if (isApplicationDocumentRequest(request)) {
return []
}
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 buildStandardAdjustmentMap(requestModel = {}) {
const flags = Array.isArray(requestModel?.riskFlags)
? requestModel.riskFlags
: Array.isArray(requestModel?.risk_flags_json)
? requestModel.risk_flags_json
: []
const adjustmentMap = new Map()
flags.forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
return
}
const itemId = String(flag.item_id || flag.itemId || '').trim()
if (!itemId) {
return
}
const originalAmount = parseOptionalCurrency(flag.original_amount ?? flag.originalAmount)
const reimbursableAmount = parseOptionalCurrency(flag.reimbursable_amount ?? flag.reimbursableAmount)
if (reimbursableAmount === null) {
return
}
const employeeAbsorbedAmount = parseOptionalCurrency(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0
adjustmentMap.set(itemId, {
originalAmount,
reimbursableAmount,
employeeAbsorbedAmount,
message: String(flag.message || flag.summary || '').trim()
})
})
return adjustmentMap
}
function resolveSourceStandardAdjustment(source, id, requestModel) {
const requestAdjustment = buildStandardAdjustmentMap(requestModel).get(id)
if (requestAdjustment) {
return requestAdjustment
}
const reimbursableAmount = parseOptionalCurrency(source?.reimbursableAmount ?? source?.reimbursable_amount)
if (reimbursableAmount === null) {
return null
}
const originalAmount = parseOptionalCurrency(
source?.originalItemAmount
?? source?.original_item_amount
?? source?.originalAmount
?? source?.original_amount
)
const employeeAbsorbedAmount = parseOptionalCurrency(
source?.employeeAbsorbedAmount
?? source?.employee_absorbed_amount
) || 0
const hasExplicitAdjustmentMarker = Boolean(
source?.standardAdjustmentAccepted
|| source?.standard_adjustment_accepted
|| source?.hasStandardAdjustment
|| source?.has_standard_adjustment
|| String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim()
|| employeeAbsorbedAmount > 0
|| (originalAmount !== null && reimbursableAmount < originalAmount)
)
if (!hasExplicitAdjustmentMarker) {
return null
}
return {
originalAmount,
reimbursableAmount,
employeeAbsorbedAmount,
message: String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim()
}
}
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 itemNote = String(source?.itemNote ?? source?.item_note ?? '').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 standardAdjustment = resolveSourceStandardAdjustment(source, id, requestModel)
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
const hasStandardAdjustment = reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const reimbursableAmountDisplay = reimbursableAmount > 0 ? formatCurrency(reimbursableAmount) : '待补充'
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,
itemNote,
itemAmount,
originalItemAmount,
originalAmountDisplay: originalItemAmount > 0 ? formatCurrency(originalItemAmount) : amountDisplay,
reimbursableAmount,
reimbursableAmountDisplay,
employeeAbsorbedAmount,
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatCurrency(employeeAbsorbedAmount) : '',
hasStandardAdjustment,
standardAdjustmentAccepted: Boolean(standardAdjustment),
standardAdjustmentMessage: standardAdjustment?.message || '',
invoiceId,
isSystemGenerated,
time: itemDate || '待补充',
filledAt: filledAt || '待同步',
dayLabel: resolveExpenseTimeLabel({
id,
itemType,
isSystemGenerated,
requestModel,
travelTimeLabelMap
}),
name: resolveExpenseTypeLabel(itemType),
category: resolveExpenseTypeLabel(itemType),
desc: itemReason || '待补充',
detail: resolveExpenseDescriptionDetail(itemType, itemLocation),
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]
.filter((item) => !isIgnorableExpenseDraftPlaceholder(item))
.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 || isSystemGeneratedExpenseItemSource(item)) {
return issues
}
if (isIgnorableExpenseDraftPlaceholder(item)) {
return issues
}
const locationRequired = isLocationRequiredExpenseType(item.itemType)
const hasUploadedReceipt = hasUploadedReceiptReference(item)
if (!hasUploadedReceipt && !isValidIsoDate(item.itemDate)) {
issues.push('缺少日期')
}
if (isPlaceholderValue(item.itemType)) {
issues.push('缺少费用项目')
}
if (!hasUploadedReceipt && isPlaceholderValue(item.itemReason)) {
issues.push('缺少说明')
} else if (!hasUploadedReceipt && isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
issues.push('行程说明格式错误')
}
if (!hasUploadedReceipt && locationRequired && isPlaceholderValue(item.itemLocation)) {
issues.push('缺少地点')
}
if (!hasUploadedReceipt && (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0)) {
issues.push('缺少金额')
}
if (isAttachmentRequiredExpenseItem(item) && !hasUploadedReceipt) {
issues.push('缺少票据标识')
}
return issues
}
export function buildDraftBlockingIssues(request, expenseItems) {
const issues = []
const isApplication = isApplicationDocumentRequest(request)
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
const effectiveItems = normalizedItems.filter((item) => !isIgnorableExpenseDraftPlaceholder(item))
const itemAmountTotal = effectiveItems.reduce((sum, item) => {
const amount = Number(item?.itemAmount || 0)
return Number.isFinite(amount) && amount > 0 ? sum + amount : sum
}, 0)
const hasValidItemDate = effectiveItems.some((item) => isValidIsoDate(item?.itemDate))
const hasValidItemType = effectiveItems.some((item) => !isPlaceholderValue(item?.itemType))
const hasValidItemReason = effectiveItems.some((item) => !isPlaceholderValue(item?.itemReason))
const hasValidItemLocation = effectiveItems.some((item) => !isPlaceholderValue(item?.itemLocation))
if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善')
}
if (isApplication) {
if (isPlaceholderValue(request.typeLabel)) {
issues.push('申请类型未完善')
}
if (isPlaceholderValue(request.reason)) {
issues.push('申请事由未完善')
}
if (isPlaceholderValue(request.location)) {
issues.push('业务地点未完善')
}
if (isPlaceholderValue(request.occurredDisplay)) {
issues.push('申请时间未完善')
}
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
issues.push('预计总费用未完善')
}
return [...new Set(issues)]
}
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 (!effectiveItems.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 '补充业务地点,方便审核业务发生场景。'
}
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}`
}