Files
X-Financial/web/src/composables/useRequests.js
caoxiaozhu 8a4a777be7 feat: 新增员工行为画像算法与费用风险标签体系
后端新增员工行为画像算法模块,支持标签规则引擎和评分计算,
完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流
和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费
用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台
样式,优化文档中心和归档中心交互,补充单元测试。
2026-05-28 12:09:49 +08:00

1095 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { computed, reactive, ref } from 'vue'
import { fetchExpenseClaims } from '../services/reimbursements.js'
import { filterActionableRiskFlags } 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 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_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
const REIMBURSEMENT_PROGRESS_LABELS = [
'创建单据',
'待提交',
'AI预审',
'直属领导审批',
'财务审批',
'待付款',
'已付款'
]
const APPLICATION_PROGRESS_LABELS = [
'创建申请',
'直属领导审批',
'预算管理者审批',
'审批完成'
]
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 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'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| 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 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 (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 === 'pending_payment') {
return '待付款'
}
if (approvalMeta.key === 'completed') {
const normalizedStatus = String(claim?.status || '').trim().toLowerCase()
return isApplicationDocument ? '审批完成' : normalizedStatus === 'paid' ? '已付款' : '归档入账'
}
return isApplicationDocument ? '直属领导审批' : '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 = filterActionableRiskFlags(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 6
}
if (approvalMeta.key === 'pending_payment') {
return 5
}
if (normalizedNode.includes('已付款')) {
return 6
}
if (normalizedNode.includes('待付款')) {
return 5
}
if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
return 6
}
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 resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') {
return 3
}
if (normalizedNode.includes('审批完成') || normalizedNode.includes('申请完成')) {
return 3
}
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 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 resolveApplicationBudgetApproverName(claim) {
const routeEvent = findApprovalEventForStep(claim, '直属领导审批')
return resolveDisplayName(
routeEvent?.next_approver_name,
routeEvent?.nextApproverName,
routeEvent?.budget_approver_name,
routeEvent?.budgetApproverName
) || 'P8预算监控者'
}
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('直属领导')
|| 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 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 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 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 === '创建单据' || 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 === 'AI预审') {
const reviewedAt = formatDateTime(claim?.submitted_at || claim?.updated_at)
return buildProgressStepMeta('AI预审通过', reviewedAt)
}
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 === '审批完成') {
const completedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta('审批完成', completedAt)
}
return buildProgressStepMeta('已完成')
}
function resolveCurrentStepStartedAt(claim, label) {
const stepLabel = normalizeText(label)
if (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 === '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 === '财务审批') {
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 === '审批完成') {
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 progressLabels =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? hasApplicationReturnStep
? ['创建申请', '直属领导审批', '退回', '待提交']
: APPLICATION_PROGRESS_LABELS
: REIMBURSEMENT_PROGRESS_LABELS
const currentIndex =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? hasApplicationReturnStep
? 3
: resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
: 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') {
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) {
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, riskSummary) {
if (!Array.isArray(claim?.items)) {
return []
}
const sortedItems = [...claim.items].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)
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 itemAmount = parseNumber(item?.item_amount)
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
return {
id,
time: formatDate(item?.item_date) || '待补充',
itemDate: formatDate(item?.item_date) || '',
filledAt: formatDateTime(item?.created_at) || '待同步',
itemType,
itemReason,
itemLocation,
itemAmount,
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: 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 documentTypeMeta = resolveDocumentTypeMeta(claim, typeCode)
const isApplicationDocument = documentTypeMeta.documentTypeCode === DOCUMENT_TYPE_APPLICATION
const approvalMeta = resolveApprovalMeta(claim?.status)
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
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,
...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: 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: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
secondaryStatusValue: isApplicationDocument
? approvalMeta.key === 'supplement'
? '领导已退回,待重新提交'
: '已进入审批流程'
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
secondaryStatusTone: isApplicationDocument
? approvalMeta.key === 'supplement' ? '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(),
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 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
}
}