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

@@ -1,12 +1,14 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApprovalInbox } from './useApprovalInbox.js'
import { useNavigation, navItems } from './useNavigation.js'
import { useRequests } from './useRequests.js'
import { useSystemState } from './useSystemState.js'
import { useToast } from './useToast.js'
import { fetchLatestConversation } from '../services/orchestrator.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { buildWorkbenchSummary } from '../utils/workbenchSummary.js'
const SESSION_TYPE_EXPENSE = 'expense'
@@ -107,6 +109,7 @@ export function useAppShell() {
} = useRequests()
const { currentUser } = useSystemState()
const { toast } = useToast()
const { refreshApprovalInbox } = useApprovalInbox()
const customRange = ref({ start: '2024-07-06', end: '2024-07-12' })
@@ -128,6 +131,7 @@ export function useAppShell() {
const detailAlerts = computed(() => (detailMode.value ? buildDetailAlerts(selectedRequest.value) : []))
const requestsListActive = computed(() => activeView.value === 'requests' && !detailMode.value)
const workbenchActive = computed(() => activeView.value === 'workbench')
watch(requestsListActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
@@ -135,6 +139,16 @@ export function useAppShell() {
}
})
watch(workbenchActive, (isActive, wasActive) => {
if (isActive && !wasActive) {
void reloadRequests()
}
})
const workbenchSummary = computed(() =>
buildWorkbenchSummary(requests.value, currentUser.value)
)
const topBarView = computed(() => {
if (detailMode.value) {
return {
@@ -250,8 +264,9 @@ export function useAppShell() {
const approvalStage = String(payload.approvalStage || payload.approval_stage || '').trim()
smartEntryOpen.value = false
await reloadRequests()
void refreshApprovalInbox()
if (status === 'submitted') {
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`)
} else {
toast(`${claimNo || '该'}单据已保存到草稿,请到报销页面查看。`)
}
@@ -271,10 +286,12 @@ export function useAppShell() {
async function handleRequestUpdated() {
await reloadRequests()
void refreshApprovalInbox()
}
async function handleRequestDeleted() {
await reloadRequests()
void refreshApprovalInbox()
router.push({ name: 'app-requests' })
}
@@ -301,6 +318,7 @@ export function useAppShell() {
openTravelCreate,
ranges,
requestSummary,
workbenchSummary,
requestsError,
requestsLoading,
reloadRequests,

View File

@@ -0,0 +1,152 @@
import { computed, ref, watch } from 'vue'
import { fetchApprovalExpenseClaims } from '../services/reimbursements.js'
import { canAccessAppView } from '../utils/accessControl.js'
import { resolvePendingClaimIds } from '../utils/approvalInbox.js'
import { useSystemState } from './useSystemState.js'
const pendingClaimIds = ref([])
const viewedClaimIds = ref([])
let refreshTimer = null
function buildStorageKey(userId) {
return `x-financial.approval-viewed:${userId}`
}
function loadViewedClaimIds(userId) {
if (!userId) {
return []
}
try {
const raw = localStorage.getItem(buildStorageKey(userId))
const parsed = raw ? JSON.parse(raw) : []
return Array.isArray(parsed)
? parsed.map((item) => String(item || '').trim()).filter(Boolean)
: []
} catch {
return []
}
}
function saveViewedClaimIds(userId, claimIds) {
if (!userId) {
return
}
localStorage.setItem(buildStorageKey(userId), JSON.stringify(claimIds))
}
function pruneViewedClaimIds(viewedIds, pendingIds) {
const pendingSet = new Set(pendingIds)
return viewedIds.filter((claimId) => pendingSet.has(claimId))
}
function syncPendingState(pendingIds, userId) {
pendingClaimIds.value = pendingIds
const pruned = pruneViewedClaimIds(viewedClaimIds.value, pendingIds)
if (pruned.length !== viewedClaimIds.value.length) {
viewedClaimIds.value = pruned
saveViewedClaimIds(userId, pruned)
}
}
export function useApprovalInbox() {
const { currentUser } = useSystemState()
const userKey = computed(() => {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
})
const unreadCount = computed(() => {
const viewedSet = new Set(viewedClaimIds.value)
return pendingClaimIds.value.filter((claimId) => !viewedSet.has(claimId)).length
})
const badgeLabel = computed(() => {
const count = unreadCount.value
if (count <= 0) {
return ''
}
return count > 99 ? '99+' : String(count)
})
function markClaimViewed(claimId) {
const normalizedId = String(claimId || '').trim()
if (!normalizedId || !pendingClaimIds.value.includes(normalizedId)) {
return
}
if (viewedClaimIds.value.includes(normalizedId)) {
return
}
const nextViewed = [...viewedClaimIds.value, normalizedId]
viewedClaimIds.value = nextViewed
saveViewedClaimIds(userKey.value, nextViewed)
}
function syncPendingClaimIds(claimIds) {
if (!canAccessAppView(currentUser.value, 'approval')) {
pendingClaimIds.value = []
return
}
const pendingIds = Array.isArray(claimIds)
? claimIds.map((item) => String(item || '').trim()).filter(Boolean)
: []
syncPendingState(pendingIds, userKey.value)
}
async function refreshApprovalInbox() {
const user = currentUser.value
if (!user || !canAccessAppView(user, 'approval')) {
pendingClaimIds.value = []
return
}
try {
const payload = await fetchApprovalExpenseClaims()
syncPendingClaimIds(resolvePendingClaimIds(payload, user))
} catch {
pendingClaimIds.value = []
}
}
function startApprovalInboxPolling(intervalMs = 45000) {
stopApprovalInboxPolling()
refreshTimer = window.setInterval(() => {
void refreshApprovalInbox()
}, intervalMs)
}
function stopApprovalInboxPolling() {
if (refreshTimer) {
window.clearInterval(refreshTimer)
refreshTimer = null
}
}
watch(
userKey,
(nextUserKey) => {
viewedClaimIds.value = loadViewedClaimIds(nextUserKey)
void refreshApprovalInbox()
},
{ immediate: true }
)
return {
pendingClaimIds,
unreadCount,
badgeLabel,
markClaimViewed,
syncPendingClaimIds,
refreshApprovalInbox,
startApprovalInboxPolling,
stopApprovalInboxPolling
}
}

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