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