import { computed, reactive, ref } from 'vue' import { fetchExpenseClaims } from '../services/reimbursements.js' const EXPENSE_TYPE_LABELS = { travel: '差旅费', entertainment: '业务招待费', office: '办公费', meeting: '会务费', training: '培训费', hotel: '住宿费', transport: '交通费', meal: '餐费', other: '其他费用' } const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([ 'travel', 'meeting', 'entertainment' ]) const REIMBURSEMENT_PROGRESS_LABELS = [ '创建单据', '待提交', 'AI预审', '直属领导审批', '财务审批', '归档入账' ] function parseNumber(value) { const nextValue = Number(value) return Number.isFinite(nextValue) ? nextValue : 0 } 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 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 resolveAttachmentDisplayName(value) { const normalized = String(value || '').trim() if (!normalized) { return '' } return normalized.split('/').filter(Boolean).pop() || normalized } 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 (['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) { if (String(claim?.status || '').trim().toLowerCase() === 'returned') { return '待提交' } const rawNode = String(claim?.approval_stage || '').trim() if (rawNode) { if (rawNode === '审批流转') { return 'AI预审' } if (rawNode === '待补充') { return approvalMeta.key === 'draft' ? '待提交' : 'AI预审' } return rawNode } if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') { return '待提交' } if (approvalMeta.key === 'completed') { return '归档入账' } return 'AI预审' } 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 '' } function buildRiskSummary(riskFlags) { if (!Array.isArray(riskFlags) || !riskFlags.length) { return '无' } const items = riskFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean) return items.length ? items.join(';') : '无' } 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 5 } if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) { return 5 } if (normalizedNode.includes('财务')) { return 4 } if ( normalizedNode.includes('直属领导') || normalizedNode.includes('领导审批') || normalizedNode.includes('部门负责人') || normalizedNode.includes('负责人审批') ) { return 3 } if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) { return 2 } if (normalizedNode.includes('待提交')) { return 1 } if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') { return 1 } return 2 } 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 } 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', '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('财务') ) } if (stepLabel === '财务审批') { return ( previousStage.includes('财务') || nextStage.includes('归档') || nextStage.includes('入账') || nextStage.includes('完成') ) } return false }) return getLatestEvent(events) } function findLatestReturnEvent(claim) { return getLatestEvent( getRiskFlags(claim).filter((flag) => ( flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return' )) ) } 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 === '创建单据') { const createdAt = formatDateTime(claim?.created_at) return buildProgressStepMeta(`${employeeName}创建`, createdAt) } if (stepLabel === '待提交') { const submittedAt = formatDateTime(claim?.submitted_at) return buildProgressStepMeta(`${employeeName}提交`, submittedAt) } if (stepLabel === 'AI预审') { const reviewedAt = formatDateTime(claim?.submitted_at || claim?.updated_at) return buildProgressStepMeta('AI预审通过', reviewedAt) } if (stepLabel === '直属领导审批' || stepLabel === '财务审批') { const approvalEvent = findApprovalEventForStep(claim, stepLabel) if (approvalEvent) { const operator = resolveDisplayName( approvalEvent.operator, approvalEvent.operator_name, approvalEvent.operatorName, stepLabel === '直属领导审批' ? claim?.manager_name : '' ) || (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 archivedAt = formatDateTime(claim?.updated_at) return buildProgressStepMeta('归档入账', archivedAt) } return buildProgressStepMeta('已完成') } function resolveCurrentStepStartedAt(claim, label) { const stepLabel = normalizeText(label) if (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 === 'AI预审') { return claim?.updated_at || claim?.submitted_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 === '归档入账') { return claim?.updated_at || claim?.submitted_at } return '' } function buildProgressSteps(approvalMeta, workflowNode, claim = {}) { const currentIndex = resolveProgressCurrentIndex(approvalMeta, workflowNode) const currentTime = approvalMeta.key === 'completed' ? '已完成' : approvalMeta.key === 'supplement' ? '待补充' : approvalMeta.key === 'rejected' ? '已退回' : '进行中' return REIMBURSEMENT_PROGRESS_LABELS.map((label, index) => { if (approvalMeta.key === 'completed') { const stepMeta = buildCompletedStepMeta(claim, label) return { index: index + 1, 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, time: stepMeta.time, detail: stepMeta.detail, title: stepMeta.title, done: true, active: true, current: false } } if (index === currentIndex) { const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label)) return { index: index + 1, label, time: stayDuration ? `停留 ${stayDuration}` : currentTime, detail: '', title: stayDuration ? `当前${label}已停留 ${stayDuration}` : currentTime, done: false, active: true, current: true } } return { index: index + 1, label, time: '待处理', detail: '', title: '待处理', done: false, active: false, current: false } }) } function buildExpenseItems(claim, riskSummary) { if (!Array.isArray(claim?.items)) { return [] } return claim.items.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 itemTypeLabel = resolveTypeLabel(itemType) const itemLocation = String(item?.item_location || '').trim() const itemReason = String(item?.item_reason || '').trim() const itemAmount = parseNumber(item?.item_amount) const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充' return { id: String(item?.id || `${claim?.id || 'claim'}-item-${index}`), time: formatDate(item?.item_date) || '待补充', itemDate: formatDate(item?.item_date) || '', filledAt: formatDateTime(item?.created_at) || '待同步', itemType, itemReason, itemLocation, itemAmount, invoiceId, dayLabel: claim?.expense_type === 'travel' ? `第 ${index + 1} 项` : '业务发生项', name: itemTypeLabel, category: itemTypeLabel, desc: itemReason || '待补充', detail: resolveLocationDisplay(itemLocation, itemType), amount: itemAmountDisplay, status: attachments.length ? '已识别' : '待补充', tone: attachments.length ? 'ok' : 'bad', attachmentStatus: attachments.length ? '已关联票据' : '未上传', attachmentHint: attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据', attachmentTone: attachments.length ? 'ok' : 'missing', attachments, riskLabel: riskSummary === '无' ? '无' : '待关注', riskText: riskSummary === '无' ? '' : riskSummary, riskTone: riskSummary === '无' ? 'low' : 'medium' } }) } export function mapExpenseClaimToRequest(claim) { const typeCode = String(claim?.expense_type || '').trim() || 'other' const typeLabel = resolveTypeLabel(typeCode) const approvalMeta = resolveApprovalMeta(claim?.status) const workflowNode = resolveWorkflowNode(claim, approvalMeta) const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count)) const riskSummary = buildRiskSummary(claim?.risk_flags_json) const expenseItems = buildExpenseItems(claim, riskSummary) const applyDateTime = claim?.submitted_at || claim?.created_at 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(), 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), roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [], entity: '', typeCode, typeLabel, detailVariant: typeCode === 'travel' ? 'travel' : 'general', title: String(claim?.reason || '').trim() || `${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 || '', amount: parseNumber(claim?.amount), riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [], invoiceCount, workflowNode, approvalKey: approvalMeta.key, approvalStatus: approvalMeta.label, approvalTone: approvalMeta.tone, secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态', secondaryStatusValue: invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据', secondaryStatusTone: invoiceCount > 0 ? 'success' : 'warning', riskSummary, attachmentSummary: invoiceCount > 0 ? `${invoiceCount} 张票据` : '无', expenseTableSummary: expenseItems.length ? (invoiceCount > 0 ? `共 ${expenseItems.length} 条费用明细,已关联 ${invoiceCount} 张票据` : `共 ${expenseItems.length} 条费用明细,待补充票据`) : '暂无费用明细', note: String(claim?.reason || '').trim(), progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim), 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 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() { loading.value = true error.value = '' try { const payload = await fetchExpenseClaims() requests.value = Array.isArray(payload) ? payload.map((item) => mapExpenseClaimToRequest(item)) : [] } catch (nextError) { requests.value = [] error.value = nextError instanceof Error ? nextError.message : '个人报销列表加载失败。' } finally { loading.value = false } } function approveRequest(request) { return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。` } function rejectRequest(request) { return `${request.id} 未执行本地状态变更,列表当前只展示后端真实数据。` } void reload() return { requests, loading, error, search, filters, ranges, activeRange, filteredRequests, approveRequest, rejectRequest, reload } }