import { isApplicationDocumentNo } from '../../utils/documentClassification.js' import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js' const EXPENSE_TYPE_LABELS = { travel: '差旅费', travel_application: '差旅费用申请', expense_application: '费用申请', purchase_application: '采购费用申请', meeting_application: '会务费用申请', train_ticket: '火车票', flight_ticket: '机票', ship_ticket: '轮船票', ferry_ticket: '轮船票', hotel_ticket: '住宿票', ride_ticket: '乘车', travel_allowance: '出差补贴', entertainment: '业务招待费', marketing: '市场推广费', office: '办公用品费', meeting: '会务费', training: '培训费', software: '软件服务费', hotel: '住宿费', transport: '交通费', meal: '业务招待费', other: '其他费用' } 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 STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment' const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket']) const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([ 'train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'hotel_ticket', 'ride_ticket' ]) const DOCUMENT_TYPE_APPLICATION = 'application' const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement' const RELATED_APPLICATION_STEP_LABEL = '关联单据' const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态' const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档' const ARCHIVED_STEP_LABEL = '已归档' const REIMBURSEMENT_PROGRESS_LABELS = [ RELATED_APPLICATION_STEP_LABEL, '待提交', '直属领导审批', '财务审批', '待付款', '已付款', ARCHIVED_STEP_LABEL ] const APPLICATION_PROGRESS_LABELS = [ '创建申请', '直属领导审批', '预算管理者审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL ] const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [ '创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL ] function parseNumber(value) { const nextValue = Number(value) return Number.isFinite(nextValue) ? nextValue : 0 } function parseOptionalAmount(value) { if (value === null || value === undefined || String(value).trim() === '') { return null } const amount = Number(value) return Number.isFinite(amount) && amount >= 0 ? amount : null } function toDate(value) { if (!value) { return null } const nextDate = new Date(value) return Number.isNaN(nextDate.getTime()) ? null : nextDate } function formatDate(value) { const nextDate = toDate(value) if (!nextDate) { return '' } const year = nextDate.getFullYear() const month = String(nextDate.getMonth() + 1).padStart(2, '0') const day = String(nextDate.getDate()).padStart(2, '0') return `${year}-${month}-${day}` } function formatDateTime(value) { const nextDate = toDate(value) if (!nextDate) { return '' } const hours = String(nextDate.getHours()).padStart(2, '0') const minutes = String(nextDate.getMinutes()).padStart(2, '0') return `${formatDate(nextDate)} ${hours}:${minutes}` } function formatDurationFrom(value, now = Date.now()) { const startAt = toDate(value) if (!startAt) { return '' } const diffMs = Math.max(0, Number(now) - startAt.getTime()) const totalMinutes = Math.floor(diffMs / (60 * 1000)) if (totalMinutes < 1) { return '刚刚' } const days = Math.floor(totalMinutes / (24 * 60)) const hours = Math.floor((totalMinutes % (24 * 60)) / 60) const minutes = totalMinutes % 60 if (days > 0) { return hours > 0 ? `${days}天${hours}小时` : `${days}天` } if (hours > 0) { return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时` } return `${minutes}分钟` } function formatAmount(value) { return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY', minimumFractionDigits: 0, maximumFractionDigits: Number.isInteger(value) ? 0 : 2 }).format(parseNumber(value)) } function resolveTypeLabel(typeCode) { return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other } function resolveDocumentTypeMeta(claim, typeCode) { const explicitType = String( claim?.document_type_code || claim?.documentTypeCode || claim?.document_type || claim?.documentType || '' ).trim() const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase() const normalizedType = String(typeCode || '').trim() const isApplication = explicitType === DOCUMENT_TYPE_APPLICATION || explicitType === 'expense_application' || isApplicationDocumentNo(claimNo) || normalizedType === 'application' || normalizedType.endsWith('_application') return isApplication ? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' } : { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' } } function normalizeExpenseType(typeCode) { return String(typeCode || '').trim() || 'other' } function resolveApprovalMeta(status) { const normalized = String(status || '').trim().toLowerCase() if (normalized === 'draft') { return { key: 'draft', label: '草稿', tone: 'draft' } } if (normalized === 'returned') { return { key: 'supplement', label: '待提交', tone: 'warning' } } if (normalized === 'supplement') { return { key: 'supplement', label: '待补充', tone: 'warning' } } if (normalized === 'pending_payment') { return { key: 'pending_payment', label: '待付款', tone: 'warning' } } if (normalized === 'paid') { return { key: 'completed', label: '已付款', tone: 'success' } } if (['approved', 'completed', 'paid'].includes(normalized)) { return { key: 'completed', label: '已完成', tone: 'success' } } if (['rejected', 'cancelled'].includes(normalized)) { return { key: 'rejected', label: '已退回', tone: 'danger' } } return { key: 'in_progress', label: '审批中', tone: 'info' } } function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) { if (String(claim?.status || '').trim().toLowerCase() === 'returned') { return '待提交' } const rawNode = String(claim?.approval_stage || '').trim() if (rawNode) { if ( isApplicationDocument && approvalMeta.key === 'completed' && ( rawNode === '审批完成' || rawNode.includes('审批完成') || rawNode.includes('申请完成') ) ) { return APPLICATION_LINK_STATUS_STEP_LABEL } if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) { return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批' } if (rawNode === '待补充') { return '待提交' } return rawNode } if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') { return '待提交' } if (approvalMeta.key === 'pending_payment') { return '待付款' } if (approvalMeta.key === 'completed') { const normalizedStatus = String(claim?.status || '').trim().toLowerCase() return isApplicationDocument ? APPLICATION_LINK_STATUS_STEP_LABEL : normalizedStatus === 'paid' ? '已付款' : '归档入账' } return '直属领导审批' } function stringifyRiskFlag(value) { if (typeof value === 'string') { return value.trim() } if (!value || typeof value !== 'object') { return '' } for (const key of ['message', 'label', 'reason', 'name']) { const nextValue = String(value[key] || '').trim() if (nextValue) { return nextValue } } return '' } const RISK_TONE_LABELS = { high: '高风险', medium: '中风险', low: '低风险' } function resolveHighestRiskTone(flags) { const tones = flags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean) if (tones.includes('high')) { return 'high' } if (tones.includes('medium')) { return 'medium' } if (tones.includes('low')) { return 'low' } return 'low' } function buildRiskMeta(riskFlags) { if (!Array.isArray(riskFlags) || !riskFlags.length) { return { summary: '无', tone: 'low', label: '无' } } const actionableFlags = filterActionableRiskFlags(riskFlags) const items = actionableFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean) if (!items.length) { return { summary: '无', tone: 'low', label: '无' } } const tone = resolveHighestRiskTone(actionableFlags) return { summary: items.join(';'), tone, label: RISK_TONE_LABELS[tone] || '待关注' } } function buildRiskSummary(riskFlags) { return buildRiskMeta(riskFlags).summary } function buildOccurredDisplay(claim) { const itemDates = Array.isArray(claim?.items) ? claim.items.map((item) => formatDate(item?.item_date)).filter(Boolean) : [] if (!itemDates.length) { return formatDate(claim?.occurred_at) || '待补充' } const sortedDates = [...new Set(itemDates)].sort() if (sortedDates.length === 1) { return sortedDates[0] } return `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}` } function normalizeText(value) { return String(value || '').trim() } function isEmailLike(value) { return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value)) } function resolveDisplayName(...values) { for (const value of values) { const normalized = normalizeText(value) if (normalized && !isEmailLike(normalized)) { return normalized } } return '' } function getRiskFlags(claim) { return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [] } function getLatestEvent(events) { const sortedEvents = events .filter((item) => item && typeof item === 'object') .map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) })) .filter((item) => item.eventDate) .sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime()) return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null } export { APPLICATION_ARCHIVE_STAGE_LABEL, APPLICATION_LINK_STATUS_STEP_LABEL, APPLICATION_PROGRESS_LABELS, APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET, ARCHIVED_STEP_LABEL, DOCUMENT_BACKED_EXPENSE_TYPES, DOCUMENT_TYPE_APPLICATION, DOCUMENT_TYPE_REIMBURSEMENT, EXPENSE_TYPE_LABELS, HOTEL_DESCRIPTION_EXPENSE_TYPES, LOCATION_REQUIRED_EXPENSE_TYPES, LONG_DISTANCE_TRAVEL_EXPENSE_TYPES, RELATED_APPLICATION_STEP_LABEL, REIMBURSEMENT_PROGRESS_LABELS, ROUTE_DESCRIPTION_EXPENSE_TYPES, STANDARD_ADJUSTMENT_RISK_SOURCE, SYSTEM_GENERATED_EXPENSE_TYPES, buildOccurredDisplay, buildRiskMeta, buildRiskSummary, formatAmount, formatDate, formatDateTime, formatDurationFrom, getLatestEvent, getRiskFlags, isEmailLike, normalizeExpenseType, normalizeText, parseNumber, parseOptionalAmount, resolveApprovalMeta, resolveDisplayName, resolveDocumentTypeMeta, resolveTypeLabel, resolveWorkflowNode, toDate }