import { computed, reactive, ref } from 'vue' import { fetchAllExpenseClaims } from '../services/reimbursements.js' 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 buildStandardAdjustmentMapFromClaim(claim = {}) { const flags = Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : Array.isArray(claim?.riskFlags) ? claim.riskFlags : [] 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() const reimbursableAmount = parseOptionalAmount(flag.reimbursable_amount ?? flag.reimbursableAmount) if (!itemId || reimbursableAmount === null) { return } adjustmentMap.set(itemId, { originalAmount: parseOptionalAmount(flag.original_amount ?? flag.originalAmount), reimbursableAmount, employeeAbsorbedAmount: parseOptionalAmount(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0, message: String(flag.message || flag.summary || '').trim() }) }) return adjustmentMap } 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 isLocationRequiredExpenseType(typeCode) { return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(typeCode)) } function resolveLocationDisplay(location, typeCode) { const normalized = String(location || '').trim() if (normalized) { return normalized } return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填' } function resolveExpenseDescriptionDetail(itemType, itemLocation) { const normalizedType = normalizeExpenseType(itemType) if (ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) { return '起始地-目的地' } if (HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizedType)) { return '目的地酒店' } return resolveLocationDisplay(itemLocation, normalizedType) } function resolveExpenseItemViewId(item, index, claim) { return String(item?.id || `${claim?.id || 'claim'}-item-${index}`) } function buildTravelTimeLabelMap(items, claim) { const travelItems = items .map((item, index) => { const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type) return { id: resolveExpenseItemViewId(item, index, claim), index, itemType, itemDate: formatDate(item?.item_date), isSystemGenerated: Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(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() 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 } function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, claim, travelTimeLabelMap }) { if (isSystemGenerated) { return '系统自动计算' } if (travelTimeLabelMap?.has(id)) { return travelTimeLabelMap.get(id) } if (itemType === 'ride_ticket') { return '乘车时间' } if (itemType === 'hotel_ticket') { return '住宿时间' } return claim?.expense_type === 'travel' ? '出行时间' : '业务发生时间' } function resolveAttachmentDisplayName(value) { const normalized = String(value || '').trim() if (!normalized) { return '' } return normalized.split('/').filter(Boolean).pop() || normalized } function hasRelatedApplicationContext(claim) { return Boolean(findRelatedApplicationEvent(claim)) } function isDocumentBackedRawExpenseItem(item) { const invoiceId = normalizeText(item?.invoice_id || item?.invoiceId) if (invoiceId) { return true } return DOCUMENT_BACKED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType)) } function extractTravelDayCount(value) { const matched = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/) return matched ? parseNumber(matched[1]) : 0 } function isStaleApplicationAllowanceRawItem(item, claim) { const itemType = normalizeExpenseType(item?.item_type || item?.itemType) if (itemType !== 'travel_allowance') { return false } const related = resolveRelatedApplicationInfo(claim) const applicationDays = extractTravelDayCount(related?.days) const itemDays = extractTravelDayCount(item?.item_reason || item?.itemReason) return applicationDays > 0 && itemDays > 0 && applicationDays !== itemDays } function isApplicationLinkPlaceholderRawItem(item, claim) { const itemType = normalizeExpenseType(item?.item_type || item?.itemType) if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) { return true } const claimType = normalizeExpenseType(claim?.expense_type || claim?.expenseType) if (itemType && claimType && itemType !== claimType) { return false } const reason = normalizeText(item?.item_reason || item?.itemReason) if (!reason || reason === '待补充') { return true } const related = resolveRelatedApplicationInfo(claim) const linkedReasons = new Set([ normalizeText(claim?.reason), normalizeText(related?.reason) ].filter(Boolean)) return linkedReasons.has(reason) } function filterVisibleExpenseRawItems(items, claim) { const rawItems = Array.isArray(items) ? items : [] if (!rawItems.length || !hasRelatedApplicationContext(claim)) { return rawItems } const hasRealExpenseItem = rawItems.some((item) => ( isDocumentBackedRawExpenseItem(item) && !SYSTEM_GENERATED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType)) )) if (!hasRealExpenseItem) { return rawItems.filter((item) => !isApplicationLinkPlaceholderRawItem(item, claim)) } return rawItems.filter((item) => { const itemType = normalizeExpenseType(item?.item_type || item?.itemType) if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) { return !isStaleApplicationAllowanceRawItem(item, claim) } return !isApplicationLinkPlaceholderRawItem(item, claim) }) } 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 resolveProgressCurrentIndex(approvalMeta, workflowNode) { const normalizedNode = String(workflowNode || '').trim() if (approvalMeta.key === 'completed') { return 6 } if (approvalMeta.key === 'pending_payment') { return 4 } if (normalizedNode.includes('已付款')) { return 5 } if (normalizedNode.includes('待付款')) { return 4 } if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) { return 6 } if (normalizedNode.includes('财务')) { return 3 } if ( normalizedNode.includes('直属领导') || normalizedNode.includes('领导审批') || normalizedNode.includes('部门负责人') || normalizedNode.includes('负责人审批') ) { return 2 } if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) { return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? 1 : 2 } if (normalizedNode.includes('待提交')) { return 1 } if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') { return 1 } return 2 } function resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) { const normalizedNode = String(workflowNode || '').trim() if (approvalMeta.key === 'completed') { return normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) ? 3 : 2 } if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) { return 3 } if (normalizedNode.includes(APPLICATION_LINK_STATUS_STEP_LABEL)) { return 2 } if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) { return 2 } if (normalizedNode.includes('预算')) { return 2 } if ( normalizedNode.includes('直属领导') || normalizedNode.includes('领导审批') || normalizedNode.includes('部门负责人') || normalizedNode.includes('负责人审批') ) { return 1 } if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') { return 0 } return 1 } function isApplicationArchivedWorkflow(claim, workflowNode) { const normalizedNode = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode) if (normalizedNode.includes(APPLICATION_ARCHIVE_STAGE_LABEL) || normalizedNode.includes(ARCHIVED_STEP_LABEL)) { return true } return getRiskFlags(claim).some((flag) => ( flag && typeof flag === 'object' && normalizeText(flag.source) === 'application_archive_sync' )) } function resolveApplicationLinkedReimbursementNo(claim) { for (const flag of [...getRiskFlags(claim)].reverse()) { if (!flag || typeof flag !== 'object') { continue } const generatedNo = normalizeText( flag.generated_draft_claim_no || flag.generatedDraftClaimNo || flag.reimbursement_claim_no || flag.reimbursementClaimNo ) if (generatedNo) { return generatedNo } } return '' } function buildApplicationLinkStatusStepMeta(claim) { const reimbursementNo = resolveApplicationLinkedReimbursementNo(claim) const updatedAt = formatDateTime(claim?.updated_at) return reimbursementNo ? buildProgressStepMeta(`关联中 ${reimbursementNo}`, updatedAt) : buildProgressStepMeta('未关联', updatedAt) } 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 resolveApplicationApproverName(claim) { return resolveDisplayName( claim?.manager_name, claim?.managerName, claim?.profile_manager, claim?.profileManager, claim?.direct_manager_name, claim?.directManagerName ) || '直属领导' } function resolveReimbursementApproverName(claim, label) { const stepLabel = normalizeText(label) if (stepLabel === '直属领导审批') { return resolveDisplayName( claim?.manager_name, claim?.managerName, claim?.profile_manager, claim?.profileManager, claim?.direct_manager_name, claim?.directManagerName ) || '直属领导' } if (stepLabel === '财务审批') { const routeEvent = findReimbursementFinanceRouteEvent(claim) return resolveDisplayName( claim?.finance_approver_name, claim?.financeApproverName, routeEvent?.next_approver_name, routeEvent?.nextApproverName, routeEvent?.finance_approver_name, routeEvent?.financeApproverName, claim?.finance_owner_name, claim?.financeOwnerName ) || '财务' } return stepLabel.replace(/审批$/, '') || '审批人' } function resolveApplicationBudgetApproverName(claim) { const routeEvent = findApprovalEventForStep(claim, '直属领导审批') return resolveDisplayName( claim?.budget_approver_name, claim?.budgetApproverName, routeEvent?.next_approver_name, routeEvent?.nextApproverName, routeEvent?.budget_approver_name, routeEvent?.budgetApproverName ) || '预算管理者' } function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) { const normalizedLabel = normalizeText(label) const workflowNode = normalizeText(claim?.approval_stage || claim?.workflowNode) if ( documentTypeCode !== DOCUMENT_TYPE_APPLICATION && approvalMeta.key !== 'completed' && normalizedLabel === '直属领导审批' && workflowNode.includes(normalizedLabel.replace(/审批$/, '')) ) { return '等待批复' } if ( documentTypeCode !== DOCUMENT_TYPE_APPLICATION && approvalMeta.key !== 'completed' && normalizedLabel === '财务审批' && workflowNode.includes(normalizedLabel.replace(/审批$/, '')) ) { return `等待 ${resolveReimbursementApproverName(claim, normalizedLabel)} 批复` } if ( documentTypeCode === DOCUMENT_TYPE_APPLICATION && approvalMeta.key !== 'completed' && normalizedLabel === '直属领导审批' && ( workflowNode.includes('直属领导') || workflowNode.includes('领导审批') || workflowNode.includes('部门负责人') || workflowNode.includes('负责人审批') ) ) { return `等待 ${resolveApplicationApproverName(claim)} 批复` } if ( documentTypeCode === DOCUMENT_TYPE_APPLICATION && approvalMeta.key !== 'completed' && normalizedLabel === '预算管理者审批' && workflowNode.includes('预算') ) { return `等待 ${resolveApplicationBudgetApproverName(claim)} 批复` } return label } 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 } function findApprovalEventForStep(claim, label) { const stepLabel = normalizeText(label) const events = getRiskFlags(claim).filter((flag) => { if (!flag || typeof flag !== 'object') { return false } const source = normalizeText(flag.source) if (!['manual_approval', 'budget_approval', 'finance_approval'].includes(source)) { return false } const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage) const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage) if (stepLabel === '直属领导审批') { return ( previousStage.includes('直属领导') || previousStage.includes('领导审批') || nextStage.includes('预算') || nextStage.includes('财务') ) } if (stepLabel === '预算管理者审批') { return ( source === 'budget_approval' || previousStage.includes('预算') || nextStage.includes('审批完成') ) } if (stepLabel === '财务审批') { return ( previousStage.includes('财务') || nextStage.includes('待付款') || nextStage.includes('归档') || nextStage.includes('入账') || nextStage.includes('完成') ) } return false }) return getLatestEvent(events) } function findReimbursementFinanceRouteEvent(claim) { return getLatestEvent( getRiskFlags(claim).filter((flag) => { if (!flag || typeof flag !== 'object') { return false } const source = normalizeText(flag.source) if (!['manual_approval', 'budget_approval'].includes(source)) { return false } const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage) return nextStage.includes('财务') }) ) } function findLatestReturnEvent(claim) { return getLatestEvent( getRiskFlags(claim).filter((flag) => ( flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return' )) ) } function findLatestPaymentEvent(claim) { return getLatestEvent( getRiskFlags(claim).filter((flag) => ( flag && typeof flag === 'object' && ( normalizeText(flag.source) === 'payment' || normalizeText(flag.event_type || flag.eventType) === 'expense_claim_payment_completed' ) )) ) } function normalizeApplicationHandoffDetail(flag = {}) { const detail = flag?.application_detail || flag?.applicationDetail || {} const reviewValues = flag?.review_form_values || flag?.reviewFormValues || {} const sceneSelection = flag?.expense_scene_selection || flag?.expenseSceneSelection || {} return [sceneSelection, reviewValues, detail] .filter((item) => item && typeof item === 'object') .reduce((acc, item) => ({ ...acc, ...item }), {}) } function resolveApplicationField(flag = {}, detail = {}, snakeKey, camelKey = '') { return normalizeText( flag?.[snakeKey] || (camelKey ? flag?.[camelKey] : '') || detail?.[snakeKey] || (camelKey ? detail?.[camelKey] : '') ) } function resolveApplicationValue(flag = {}, detail = {}, keys = []) { for (const key of keys) { const detailValue = normalizeText(detail?.[key]) if (detailValue) { return detailValue } const flagValue = normalizeText(flag?.[key]) if (flagValue) { return flagValue } } return '' } function extractDateRange(value) { const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || [] if (!dates.length) { return { startDate: '', endDate: '' } } return { startDate: dates[0], endDate: dates[dates.length - 1] } } function resolveRelatedApplicationClaimNo(flag = {}) { const detail = normalizeApplicationHandoffDetail(flag) return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo') } function findRelatedApplicationEvent(claim) { const events = getRiskFlags(claim).filter((flag) => ( flag && typeof flag === 'object' && resolveRelatedApplicationClaimNo(flag) )) return getLatestEvent(events) || events[events.length - 1] || null } function resolveRelatedApplicationAmountLabel(flag, detail, claim) { const explicitLabel = normalizeText( flag?.application_amount_label || flag?.applicationAmountLabel || detail?.application_amount_label || detail?.applicationAmountLabel ) if (explicitLabel) return explicitLabel const rawAmount = normalizeText( flag?.application_amount || flag?.applicationAmount || flag?.application_budget_amount || flag?.applicationBudgetAmount || detail?.application_amount || detail?.applicationAmount || detail?.amount || claim?.amount ) const amountValue = parseNumber(rawAmount) return amountValue > 0 ? formatAmount(amountValue) : rawAmount } function resolveRelatedApplicationInfo(claim, typeLabel = '') { const relatedEvent = findRelatedApplicationEvent(claim) if (!relatedEvent) { return null } const detail = normalizeApplicationHandoffDetail(relatedEvent) const claimNo = resolveRelatedApplicationClaimNo(relatedEvent) const applicationType = normalizeText( detail.application_type || detail.applicationType || relatedEvent.application_type || relatedEvent.applicationType || typeLabel ) const location = normalizeText( detail.application_location || detail.applicationLocation || detail.location || relatedEvent.application_location || relatedEvent.applicationLocation || claim?.location ) const reason = normalizeText( detail.application_reason || detail.applicationReason || detail.reason || relatedEvent.application_reason || relatedEvent.applicationReason || claim?.reason ) const content = normalizeText( detail.application_content || detail.applicationContent || relatedEvent.application_content || relatedEvent.applicationContent ) || [applicationType, location].filter(Boolean).join(' / ') const rawTime = normalizeText( detail.application_time || detail.applicationTime || detail.application_business_time || detail.applicationBusinessTime || detail.business_time || detail.businessTime || detail.time_range || detail.timeRange || detail.time || detail.application_date || detail.applicationDate || relatedEvent.application_time || relatedEvent.applicationTime || relatedEvent.application_business_time || relatedEvent.applicationBusinessTime || relatedEvent.business_time || relatedEvent.businessTime || relatedEvent.time_range || relatedEvent.timeRange || relatedEvent.application_date || relatedEvent.applicationDate || claim?.occurred_at ) const displayTime = formatDate(rawTime) || rawTime const dateRange = extractDateRange(rawTime || displayTime) const ruleName = resolveApplicationValue(relatedEvent, detail, [ 'application_rule_name', 'applicationRuleName', 'rule_name', 'ruleName' ]) const ruleVersion = resolveApplicationValue(relatedEvent, detail, [ 'application_rule_version', 'applicationRuleVersion', 'rule_version', 'ruleVersion' ]) return { id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'), claimNo, content, reason, days: normalizeText( detail.application_days || detail.applicationDays || detail.days || relatedEvent.application_days || relatedEvent.applicationDays ), location, time: displayTime, tripStartDate: dateRange.startDate, tripEndDate: dateRange.endDate, amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim), statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'), transportMode: normalizeText( detail.application_transport_mode || detail.applicationTransportMode || detail.transport_mode || relatedEvent.application_transport_mode || relatedEvent.applicationTransportMode ), lodgingDailyCap: resolveApplicationValue(relatedEvent, detail, [ 'application_lodging_daily_cap', 'applicationLodgingDailyCap', 'lodging_daily_cap', 'lodgingDailyCap' ]), subsidyDailyCap: resolveApplicationValue(relatedEvent, detail, [ 'application_subsidy_daily_cap', 'applicationSubsidyDailyCap', 'subsidy_daily_cap', 'subsidyDailyCap' ]), transportPolicy: resolveApplicationValue(relatedEvent, detail, [ 'application_transport_policy', 'applicationTransportPolicy', 'transport_policy', 'transportPolicy' ]), policyEstimate: resolveApplicationValue(relatedEvent, detail, [ 'application_policy_estimate', 'applicationPolicyEstimate', 'policy_estimate', 'policyEstimate' ]), ruleName, ruleVersion, ruleLabel: [ruleName, ruleVersion].filter(Boolean).join(' / ') } } function findLatestApplicationReturnEvent(claim) { return getLatestEvent( getRiskFlags(claim).filter((flag) => { if (!flag || typeof flag !== 'object' || normalizeText(flag.source) !== 'manual_return') { return false } const eventType = normalizeText(flag.event_type || flag.eventType) const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage) const stageKey = normalizeText(flag.return_stage_key || flag.returnStageKey) return ( eventType === 'expense_application_return' || stageKey === 'direct_manager' || returnStage.includes('直属领导') || returnStage.includes('领导审批') ) }) ) } function findMergedApplicationBudgetApprovalEvent(claim) { return getLatestEvent( getRiskFlags(claim).filter((flag) => { if (!flag || typeof flag !== 'object') { return false } const source = normalizeText(flag.source) const eventType = normalizeText(flag.event_type || flag.eventType) const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage) const nextStage = normalizeText(flag.next_approval_stage || flag.nextApprovalStage) const mergedFlag = Boolean(flag.budget_approval_merged || flag.budgetApprovalMerged) return ( source === 'manual_approval' && eventType === 'expense_application_approval' && previousStage.includes('直属领导') && ( nextStage.includes('审批完成') || nextStage.includes(APPLICATION_LINK_STATUS_STEP_LABEL) || nextStage.includes('申请完成') ) && mergedFlag ) }) ) } function resolveBudgetRouteResult(flag, routeDecision = {}) { if (routeDecision && typeof routeDecision === 'object') { const routeBudgetResult = routeDecision.budget_result || routeDecision.budgetResult if (routeBudgetResult && typeof routeBudgetResult === 'object') { return routeBudgetResult } } const flagBudgetResult = flag?.budget_result || flag?.budgetResult return flagBudgetResult && typeof flagBudgetResult === 'object' ? flagBudgetResult : {} } function applicationBudgetRouteMeetsThreshold(flag, routeDecision = {}) { const budgetResult = resolveBudgetRouteResult(flag, routeDecision) const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {} const overBudgetAmount = parseNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount) const afterUsageRate = parseNumber(metrics.after_usage_rate ?? metrics.afterUsageRate) const claimAmountRatio = parseNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio) return overBudgetAmount > 0 || Math.max(afterUsageRate, claimAmountRatio) >= 90 } function applicationRequiresBudgetReviewStep(claim, workflowNode) { const node = normalizeText(workflowNode || claim?.approval_stage || claim?.workflowNode) if (node.includes('预算')) { return true } return getRiskFlags(claim).some((flag) => { if (!flag || typeof flag !== 'object') { return false } const source = normalizeText(flag.source) const eventType = normalizeText(flag.event_type || flag.eventType) const previousStage = normalizeText(flag.previous_approval_stage || flag.previousApprovalStage) const routeDecision = flag.route_decision || flag.routeDecision || {} if (source === 'approval_routing' && flag.requires_budget_review === true) { return applicationBudgetRouteMeetsThreshold(flag, flag) } if ( routeDecision && typeof routeDecision === 'object' && routeDecision.requires_budget_review === true ) { return applicationBudgetRouteMeetsThreshold(flag, routeDecision) } return ( source === 'budget_approval' || eventType === 'expense_application_budget_approval' || previousStage.includes('预算') ) }) } function buildProgressStepMeta(time, detail = '', title = '') { return { time, detail, title: title || [time, detail].filter(Boolean).join(' ') } } function buildCompletedStepMeta(claim, label) { const stepLabel = normalizeText(label) const employeeName = normalizeText(claim?.employee_name) || '申请人' if (stepLabel === RELATED_APPLICATION_STEP_LABEL) { const relatedApplication = resolveRelatedApplicationInfo(claim) const createdAt = formatDateTime(claim?.created_at) if (relatedApplication?.claimNo) { return buildProgressStepMeta(`已关联 ${relatedApplication.claimNo}`, createdAt) } return buildProgressStepMeta('待核对关联单据', createdAt) } if (stepLabel === APPLICATION_LINK_STATUS_STEP_LABEL) { return buildApplicationLinkStatusStepMeta(claim) } if (stepLabel === '创建单据' || stepLabel === '创建申请') { const createdAt = formatDateTime(claim?.created_at) return buildProgressStepMeta(stepLabel === '创建申请' ? `${employeeName}发起申请` : `${employeeName}创建`, createdAt) } if (stepLabel === '待提交') { const submittedAt = formatDateTime(claim?.submitted_at) return buildProgressStepMeta(`${employeeName}提交`, submittedAt) } if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') { const approvalEvent = findApprovalEventForStep(claim, stepLabel) if (approvalEvent) { const operator = resolveDisplayName( approvalEvent.operator, approvalEvent.operator_name, approvalEvent.operatorName, stepLabel === '直属领导审批' ? claim?.manager_name : '', stepLabel === '预算管理者审批' ? approvalEvent.next_approver_name : '' ) || (stepLabel === '财务审批' ? '财务' : stepLabel === '预算管理者审批' ? '预算管理者' : '直属领导') const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt) return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim()) } if (stepLabel === '财务审批') { const updatedAt = formatDateTime(claim?.updated_at) return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim()) } if (stepLabel === '直属领导审批') { const returnEvent = findLatestApplicationReturnEvent(claim) if (returnEvent) { const handledAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt) return buildProgressStepMeta('已处理', handledAt, `直属领导已处理 ${handledAt}`.trim()) } } } if (stepLabel === '退回') { const returnEvent = findLatestApplicationReturnEvent(claim) || findLatestReturnEvent(claim) if (returnEvent) { const operator = resolveDisplayName( returnEvent.operator, returnEvent.operator_name, returnEvent.operatorName, claim?.manager_name ) || '直属领导' const returnedAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt) return buildProgressStepMeta(`${operator}退回`, returnedAt, `${operator}退回 ${returnedAt}`.trim()) } } if (stepLabel === '待付款') { const approvalEvent = findApprovalEventForStep(claim, '财务审批') const pendingAt = formatDateTime(approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at) return buildProgressStepMeta('待付款', pendingAt) } if (stepLabel === '已付款') { const paymentEvent = findLatestPaymentEvent(claim) const paidAt = formatDateTime(paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at) return buildProgressStepMeta('已付款', paidAt) } if (stepLabel === '归档入账') { const archivedAt = formatDateTime(claim?.updated_at) return buildProgressStepMeta('归档入账', archivedAt) } if (stepLabel === ARCHIVED_STEP_LABEL) { const archivedAt = formatDateTime(claim?.updated_at) return buildProgressStepMeta(ARCHIVED_STEP_LABEL, archivedAt) } if (stepLabel === '审批完成') { const completedAt = formatDateTime(claim?.updated_at) return buildProgressStepMeta('审批完成', completedAt) } return buildProgressStepMeta('已完成') } function resolveCurrentStepStartedAt(claim, label) { const stepLabel = normalizeText(label) if (stepLabel === RELATED_APPLICATION_STEP_LABEL || stepLabel === '创建单据' || stepLabel === '创建申请') { return claim?.created_at } if (stepLabel === '待提交') { const returnEvent = findLatestReturnEvent(claim) return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at } if (stepLabel === '直属领导审批') { return claim?.submitted_at || claim?.updated_at || claim?.created_at } if (stepLabel === '预算管理者审批') { const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批') return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at } if (stepLabel === '财务审批') { const leaderApprovalEvent = findApprovalEventForStep(claim, '直属领导审批') return leaderApprovalEvent?.created_at || leaderApprovalEvent?.createdAt || claim?.updated_at || claim?.submitted_at } if (stepLabel === '待付款') { const approvalEvent = findApprovalEventForStep(claim, '财务审批') return approvalEvent?.created_at || approvalEvent?.createdAt || claim?.updated_at || claim?.submitted_at } if (stepLabel === '已付款') { const paymentEvent = findLatestPaymentEvent(claim) return paymentEvent?.created_at || paymentEvent?.createdAt || claim?.updated_at || claim?.submitted_at } if (stepLabel === '归档入账' || stepLabel === ARCHIVED_STEP_LABEL || stepLabel === '审批完成') { return claim?.updated_at || claim?.submitted_at } return '' } function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) { const documentTypeCode = String(options.documentTypeCode || '').trim() const hasApplicationReturnStep = ( documentTypeCode === DOCUMENT_TYPE_APPLICATION && Boolean(findLatestApplicationReturnEvent(claim)) && approvalMeta.key === 'supplement' ) const hasMergedApplicationBudgetApproval = ( documentTypeCode === DOCUMENT_TYPE_APPLICATION && Boolean(findMergedApplicationBudgetApprovalEvent(claim)) ) const shouldShowApplicationBudgetStep = ( documentTypeCode === DOCUMENT_TYPE_APPLICATION && !hasMergedApplicationBudgetApproval && applicationRequiresBudgetReviewStep(claim, workflowNode) ) const isApplicationDocument = documentTypeCode === DOCUMENT_TYPE_APPLICATION const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode) const progressLabels = isApplicationDocument ? hasApplicationReturnStep ? ['创建申请', '直属领导审批', '退回', '待提交'] : hasMergedApplicationBudgetApproval ? ['创建申请', '直属领导审批', APPLICATION_LINK_STATUS_STEP_LABEL, ARCHIVED_STEP_LABEL] : shouldShowApplicationBudgetStep ? APPLICATION_PROGRESS_LABELS : APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET : REIMBURSEMENT_PROGRESS_LABELS const applicationLinkIndex = progressLabels.indexOf(APPLICATION_LINK_STATUS_STEP_LABEL) const applicationArchiveIndex = progressLabels.indexOf(ARCHIVED_STEP_LABEL) const currentIndex = isApplicationDocument ? hasApplicationReturnStep ? 3 : applicationArchived && applicationArchiveIndex >= 0 ? applicationArchiveIndex : approvalMeta.key === 'completed' && applicationLinkIndex >= 0 ? applicationLinkIndex : Math.min( resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode), Math.max(0, progressLabels.length - 1) ) : resolveProgressCurrentIndex(approvalMeta, workflowNode) const currentTime = approvalMeta.key === 'completed' ? '已完成' : approvalMeta.key === 'pending_payment' ? '待付款' : approvalMeta.key === 'supplement' ? '待补充' : approvalMeta.key === 'rejected' ? '已退回' : '进行中' return progressLabels.map((label, index) => { const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) if (approvalMeta.key === 'completed' && (!isApplicationDocument || applicationArchived)) { const stepMeta = buildCompletedStepMeta(claim, label) return { index: index + 1, label: displayLabel, rawLabel: label, time: stepMeta.time, detail: stepMeta.detail, title: stepMeta.title, done: true, active: true, current: false } } if (index < currentIndex) { const stepMeta = buildCompletedStepMeta(claim, label) return { index: index + 1, label: displayLabel, rawLabel: label, time: stepMeta.time, detail: stepMeta.detail, title: stepMeta.title, done: true, active: true, current: false } } if (index === currentIndex) { if (isApplicationDocument && label === APPLICATION_LINK_STATUS_STEP_LABEL) { const stepMeta = buildApplicationLinkStatusStepMeta(claim) return { index: index + 1, label: displayLabel, rawLabel: label, time: stepMeta.time, detail: stepMeta.detail, title: stepMeta.title, done: false, active: true, current: true } } const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label)) return { index: index + 1, label: displayLabel, rawLabel: label, time: stayDuration ? `停留 ${stayDuration}` : currentTime, detail: '', title: stayDuration ? `当前${displayLabel}已停留 ${stayDuration}` : currentTime, done: false, active: true, current: true } } return { index: index + 1, label: displayLabel, rawLabel: label, time: '待处理', detail: '', title: '待处理', done: false, active: false, current: false } }) } function buildExpenseItems(claim, riskMeta) { if (!Array.isArray(claim?.items)) { return [] } const normalizedRiskMeta = typeof riskMeta === 'string' ? { summary: riskMeta, tone: riskMeta === '无' ? 'low' : 'medium', label: riskMeta === '无' ? '无' : '待关注' } : { summary: String(riskMeta?.summary || '无').trim() || '无', tone: String(riskMeta?.tone || 'low').trim() || 'low', label: String(riskMeta?.label || '').trim() || (String(riskMeta?.summary || '').trim() === '无' ? '无' : '待关注') } const visibleItems = filterVisibleExpenseRawItems(claim.items, claim) const sortedItems = [...visibleItems].sort((left, right) => { const leftType = normalizeExpenseType(left?.item_type) const rightType = normalizeExpenseType(right?.item_type) return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType)) }) const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim) const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim) return sortedItems.map((item, index) => { const invoiceId = String(item?.invoice_id || '').trim() const attachmentName = resolveAttachmentDisplayName(invoiceId) const attachments = invoiceId ? [attachmentName || invoiceId] : [] const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type) const isSystemGenerated = Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) const id = resolveExpenseItemViewId(item, index, claim) const itemTypeLabel = resolveTypeLabel(itemType) const itemLocation = String(item?.item_location || '').trim() const itemReason = String(item?.item_reason || '').trim() const itemNote = String(item?.item_note || item?.itemNote || '').trim() const itemAmount = parseNumber(item?.item_amount) const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充' const standardAdjustment = standardAdjustmentMap.get(id) || null const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0) return { id, time: formatDate(item?.item_date) || '待补充', itemDate: formatDate(item?.item_date) || '', filledAt: formatDateTime(item?.created_at) || '待同步', itemType, itemReason, itemLocation, itemNote, itemAmount, originalItemAmount, originalAmountDisplay: originalItemAmount > 0 ? formatAmount(originalItemAmount) : itemAmountDisplay, reimbursableAmount, reimbursableAmountDisplay: reimbursableAmount > 0 ? formatAmount(reimbursableAmount) : '待补充', employeeAbsorbedAmount, employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatAmount(employeeAbsorbedAmount) : '', hasStandardAdjustment: reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount, standardAdjustmentAccepted: Boolean(standardAdjustment), standardAdjustmentMessage: standardAdjustment?.message || '', invoiceId, isSystemGenerated, dayLabel: resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, claim, travelTimeLabelMap }), name: itemTypeLabel, category: itemTypeLabel, desc: itemReason || '待补充', detail: resolveExpenseDescriptionDetail(itemType, itemLocation), amount: itemAmountDisplay, status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充', tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad', attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传', attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据', attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing', attachments, riskLabel: normalizedRiskMeta.summary === '无' ? '无' : normalizedRiskMeta.label, riskText: normalizedRiskMeta.summary === '无' ? '' : normalizedRiskMeta.summary, riskTone: normalizedRiskMeta.summary === '无' ? 'low' : normalizedRiskMeta.tone } }) } export function mapExpenseClaimToRequest(claim) { const typeCode = String(claim?.expense_type || '').trim() || 'other' const typeLabel = resolveTypeLabel(typeCode) const documentTypeMeta = resolveDocumentTypeMeta(claim, typeCode) const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION const approvalMeta = resolveApprovalMeta(claim?.status) const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument) const applicationArchived = isApplicationDocument && isApplicationArchivedWorkflow(claim, workflowNode) const applicationLinkedReimbursementNo = isApplicationDocument ? resolveApplicationLinkedReimbursementNo(claim) : '' const applicationLinkStatusText = applicationLinkedReimbursementNo ? `关联中 ${applicationLinkedReimbursementNo}` : '未关联' const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count)) const riskMeta = buildRiskMeta(claim?.risk_flags_json) const riskSummary = riskMeta.summary const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel) const expenseItems = buildExpenseItems(claim, riskMeta) const visibleExpenseAmount = expenseItems.reduce((sum, item) => { const amount = parseOptionalAmount(item.reimbursableAmount) ?? parseNumber(item.itemAmount) return sum + amount }, 0) const amountValue = relatedApplication ? expenseItems.length ? visibleExpenseAmount : invoiceCount === 0 ? 0 : parseNumber(claim?.amount) : parseNumber(claim?.amount) const applyDateTime = claim?.submitted_at || claim?.created_at const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim() const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim() return { id: String(claim?.claim_no || claim?.id || '').trim(), claimNo: String(claim?.claim_no || claim?.id || '').trim(), claimId: String(claim?.id || '').trim(), status: String(claim?.status || '').trim(), employeeId, employee_id: employeeId, profileEmployeeId: employeeId || employeeName, person: String(claim?.employee_name || '').trim() || '待补充', dept: String(claim?.department_name || '').trim() || '待补充', departmentName: String(claim?.department_name || '').trim() || '待补充', employeeName: String(claim?.employee_name || '').trim() || '待补充', employeePosition: String(claim?.employee_position || '').trim(), employeeGrade: String(claim?.employee_grade || '').trim(), managerName: resolveDisplayName(claim?.manager_name), financeApproverName: resolveDisplayName(claim?.finance_approver_name, claim?.financeApproverName), financeOwnerName: resolveDisplayName(claim?.finance_owner_name, claim?.financeOwnerName), budgetApproverName: resolveDisplayName(claim?.budget_approver_name, claim?.budgetApproverName), budgetApproverGrade: String(claim?.budget_approver_grade || claim?.budgetApproverGrade || '').trim(), budgetApproverRoleCode: String(claim?.budget_approver_role_code || claim?.budgetApproverRoleCode || '').trim(), roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [], entity: '', typeCode, typeLabel, ...documentTypeMeta, detailVariant: typeCode === 'travel' || typeCode === 'travel_application' ? 'travel' : 'general', title: String(claim?.reason || '').trim() || (isApplicationDocument ? typeLabel : `${typeLabel}报销`), sceneLabel: typeLabel, sceneTarget: String(claim?.location || '').trim() || '待补充', location: String(claim?.location || '').trim() || '待补充', relatedCustomer: '', occurredDisplay: buildOccurredDisplay(claim), occurredAt: claim?.occurred_at || '', applyTime: formatDateTime(applyDateTime) || '待补充', submittedAt: applyDateTime || '', createdAt: claim?.created_at || '', updatedAt: claim?.updated_at || '', amount: amountValue, riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [], riskTone: riskMeta.tone, riskLabel: riskMeta.label, invoiceCount, workflowNode, approvalKey: approvalMeta.key, approvalStatus: approvalMeta.label, approvalTone: approvalMeta.tone, secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'), secondaryStatusValue: isApplicationDocument ? approvalMeta.key === 'supplement' ? '领导已退回,待重新提交' : applicationArchived ? '已归档' : approvalMeta.key === 'completed' ? applicationLinkStatusText : '已进入审批流程' : (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'), secondaryStatusTone: isApplicationDocument ? approvalMeta.key === 'supplement' ? 'warning' : approvalMeta.key === 'completed' && !applicationArchived && !applicationLinkedReimbursementNo ? 'warning' : 'success' : (invoiceCount > 0 ? 'success' : 'warning'), riskSummary, attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'), expenseTableSummary: isApplicationDocument ? '预计金额已随申请提交' : expenseItems.length ? (invoiceCount > 0 ? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据` : `共 ${expenseItems.length} 条费用明细,待补充票据`) : '暂无费用明细', note: String(claim?.reason || '').trim(), relatedApplication, progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim, { documentTypeCode: documentTypeMeta.documentTypeCode }), expenseItems } } function getWeekStart(date) { const nextDate = new Date(date) const day = nextDate.getDay() || 7 nextDate.setHours(0, 0, 0, 0) nextDate.setDate(nextDate.getDate() - day + 1) return nextDate } function getRecentDaysStart(date, days) { const nextDate = new Date(date) nextDate.setHours(0, 0, 0, 0) nextDate.setDate(nextDate.getDate() - Math.max(0, Number(days || 1) - 1)) return nextDate } function resolveRangeMatch(activeRange, item) { if (activeRange === 'custom' || activeRange === '本月') { if (activeRange !== '本月') { return true } } const targetDate = toDate(item?.submittedAt || item?.createdAt || item?.occurredAt) if (!targetDate) { return true } const now = new Date() const targetDay = formatDate(targetDate) if (activeRange === '今日') { return targetDay === formatDate(now) } if (activeRange === '近10日') { const recentStart = getRecentDaysStart(now, 10) return targetDate >= recentStart && targetDate <= now } if (activeRange === '本周') { const weekStart = getWeekStart(now) const nextWeekStart = new Date(weekStart) nextWeekStart.setDate(nextWeekStart.getDate() + 7) return targetDate >= weekStart && targetDate < nextWeekStart } if (activeRange === '本月') { return ( targetDate.getFullYear() === now.getFullYear() && targetDate.getMonth() === now.getMonth() ) } return true } export function useRequests() { const requests = ref([]) const loading = ref(false) const loaded = ref(false) const error = ref('') const search = ref('') const filters = reactive({ entity: '全部主体', category: '全部类型', risk: '全部状态' }) const ranges = ['今日', '近10日', '本周', '本月'] const activeRange = ref('近10日') const filteredRequests = computed(() => { const key = search.value.trim().toLowerCase() return requests.value.filter((item) => { const searchText = [ item.id, item.person, item.typeLabel, item.title, item.sceneTarget, item.riskSummary ] .filter(Boolean) .join('') .toLowerCase() const matchesSearch = !key || searchText.includes(key) const matchesRange = resolveRangeMatch(activeRange.value, item) return matchesSearch && matchesRange }) }) async function reload(options = {}) { const silent = Boolean(options?.silent) if (!silent) { loading.value = true error.value = '' } try { const payload = await fetchAllExpenseClaims() requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : [] loaded.value = true } catch (nextError) { if (!silent) { requests.value = [] } error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。' } finally { if (!silent) { loading.value = false } } } function approveRequest(request) { return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。` } function rejectRequest(request) { return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。` } function ensureLoaded() { return loaded.value ? Promise.resolve() : reload() } return { requests, loading, loaded, error, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest, ensureLoaded, reload } }