Files
X-Financial/web/src/views/scripts/travelRequestDetailExpenseModel.js
caoxiaozhu ca691f3ee0 feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
2026-06-02 14:01:51 +08:00

597 lines
21 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 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 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 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 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: completed ? '已完成' : '待处理',
done: completed,
active: completed,
current: false
}
]
}
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 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 || '待补充',
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]
.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 (isAttachmentRequiredExpenseItem(item) && isPlaceholderValue(item.invoiceId)) {
issues.push('缺少票据标识')
}
return issues
}
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}`
}