feat: 完善报销单审批流程及退回原因追踪

新增直属领导审批通过接口和审批待办列表查询,报销单退回
支持原因码分类和审批环节标记,优化票据附件去重和路径
回退查找,前端新增退回原因对话框、审批收件箱和工作台
图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-20 21:00:47 +08:00
parent f8b25a7ccc
commit 002bf4f756
62 changed files with 5331 additions and 2101 deletions

View File

@@ -66,6 +66,33 @@ function formatDateTime(value) {
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',
@@ -239,7 +266,147 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
return 2
}
function buildProgressSteps(approvalMeta, workflowNode) {
function normalizeText(value) {
return String(value || '').trim()
}
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 = normalizeText(approvalEvent.operator) || (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'
@@ -252,10 +419,13 @@ function buildProgressSteps(approvalMeta, workflowNode) {
return REIMBURSEMENT_PROGRESS_LABELS.map((label, index) => {
if (approvalMeta.key === 'completed') {
const stepMeta = buildCompletedStepMeta(claim, label)
return {
index: index + 1,
label,
time: '已完成',
time: stepMeta.time,
detail: stepMeta.detail,
title: stepMeta.title,
done: true,
active: true,
current: false
@@ -263,10 +433,13 @@ function buildProgressSteps(approvalMeta, workflowNode) {
}
if (index < currentIndex) {
const stepMeta = buildCompletedStepMeta(claim, label)
return {
index: index + 1,
label,
time: '已完成',
time: stepMeta.time,
detail: stepMeta.detail,
title: stepMeta.title,
done: true,
active: true,
current: false
@@ -274,10 +447,13 @@ function buildProgressSteps(approvalMeta, workflowNode) {
}
if (index === currentIndex) {
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
return {
index: index + 1,
label,
time: currentTime,
time: stayDuration ? `停留 ${stayDuration}` : currentTime,
detail: '',
title: stayDuration ? `当前${label}已停留 ${stayDuration}` : currentTime,
done: false,
active: true,
current: true
@@ -288,6 +464,8 @@ function buildProgressSteps(approvalMeta, workflowNode) {
index: index + 1,
label,
time: '待处理',
detail: '',
title: '待处理',
done: false,
active: false,
current: false
@@ -315,6 +493,7 @@ function buildExpenseItems(claim, riskSummary) {
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,
@@ -328,8 +507,8 @@ function buildExpenseItems(claim, riskSummary) {
amount: itemAmountDisplay,
status: attachments.length ? '已识别' : '待补充',
tone: attachments.length ? 'ok' : 'bad',
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
attachmentHint: attachments.length ? attachments[0] : '支持上传 JPG、PNG、PDF,未上传也可先保存草稿',
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
attachmentHint: attachments.length ? attachments[0] : '支持上传 1 张 JPG、PNG、PDF 单据',
attachmentTone: attachments.length ? 'ok' : 'missing',
attachments,
riskLabel: riskSummary === '无' ? '无' : '待关注',
@@ -394,7 +573,7 @@ export function mapExpenseClaimToRequest(claim) {
: `${expenseItems.length} 条费用明细,待补充票据`)
: '暂无费用明细',
note: String(claim?.reason || '').trim(),
progressSteps: buildProgressSteps(approvalMeta, workflowNode),
progressSteps: buildProgressSteps(approvalMeta, workflowNode, claim),
expenseItems
}
}