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 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 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: 'AI预审', time: '待处理' }, { index: 4, label: '直属领导审批', time: '待处理' }, { index: 5, label: '财务审批', time: '待处理' }, { index: 6, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment }, { index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }, { index: 8, 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 (isPlaceholderValue(item.invoiceId)) { issues.push('缺少票据标识') } return issues } export function buildOptionalTravelReceiptRiskCards(requestModel, items) { if (isApplicationDocumentRequest(requestModel)) { return [] } 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', businessStage: 'reimbursement', tone: 'low', label: '低风险', title: '住宿票据提醒', risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。', summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。', ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'], suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。' }) } if (!hasUploadedType('ride_ticket')) { cards.push({ id: 'travel-optional-ride-ticket', businessStage: 'reimbursement', 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}。` }