feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user