Files
X-Financial/web/src/composables/useRequests.js
caoxiaozhu 57957d11a0 feat: 重构报销单AI预审流程并添加平台风险规则引擎
- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核
- 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器
- 用户上下文增加部门信息(department_name),认证流程同步关联组织架构
- 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类
- 新增orchestrator审核流程测试用例
- 前端更新审计视图、差旅报销等相关页面
2026-05-20 09:36:01 +08:00

521 lines
15 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',
'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('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'
}
})
}
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: String(claim?.manager_name || '').trim(),
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),
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
}
}