Files
X-Financial/web/src/composables/useRequests.js
caoxiaozhu 6c8947f40f feat(web): update composables
- useAppShell.js: update app shell composable
- useRequests.js: update requests composable
2026-05-13 06:48:27 +00:00

506 lines
14 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'
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
entertainment: '业务招待费',
office: '办公费',
meeting: '会务费',
training: '培训费',
hotel: '住宿费',
transport: '交通费',
meal: '餐费',
other: '其他费用'
}
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'hotel',
'transport',
'meal',
'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 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 (['supplement', 'returned'].includes(normalized)) {
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) {
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('审批流转')) {
return 2
}
if (normalizedNode.includes('待提交')) {
return 1
}
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
return 1
}
return 2
}
function buildProgressSteps(approvalMeta, workflowNode) {
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') {
return {
index: index + 1,
label,
time: '已完成',
done: true,
active: true,
current: false
}
}
if (index < currentIndex) {
return {
index: index + 1,
label,
time: '已完成',
done: true,
active: true,
current: false
}
}
if (index === currentIndex) {
return {
index: index + 1,
label,
time: currentTime,
done: false,
active: true,
current: true
}
}
return {
index: index + 1,
label,
time: '待处理',
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) || '',
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 ? `${attachments.length} 份附件` : '未上传',
attachmentHint: attachments.length ? attachments[0] : '支持上传 JPG、PNG、PDF未上传也可先保存草稿',
attachmentTone: attachments.length ? 'ok' : 'missing',
attachments,
riskLabel: riskSummary === '无' ? '无' : '待关注',
riskText: riskSummary === '无' ? '' : riskSummary,
riskTone: riskSummary === '无' ? 'low' : 'medium'
}
})
}
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() || '待补充',
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),
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),
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 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 === '本周') {
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 = ['今日', '本周', '本月']
const activeRange = ref('本周')
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
}
}