Files
X-Financial/web/src/composables/useRequests.js

506 lines
14 KiB
JavaScript
Raw Normal View History

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
}
}