feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
@@ -3,7 +3,9 @@ import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import ReturnReasonDialog from '../../components/shared/ReturnReasonDialog.vue'
|
||||
import {
|
||||
approveExpenseClaim,
|
||||
createExpenseClaimItem,
|
||||
deleteExpenseClaimItem,
|
||||
deleteExpenseClaimItemAttachment,
|
||||
@@ -15,8 +17,13 @@ import {
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards
|
||||
} from './travelRequestDetailInsights.js'
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
@@ -30,21 +37,6 @@ const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
meal_receipt: '餐饮票据',
|
||||
office_invoice: '办公用品票据',
|
||||
meeting_invoice: '会议/会务票据',
|
||||
training_invoice: '培训票据',
|
||||
vat_invoice: '增值税发票',
|
||||
receipt: '一般收据/凭证',
|
||||
other: '其他单据'
|
||||
}
|
||||
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'meeting',
|
||||
@@ -72,18 +64,10 @@ function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function resolveLocationInputPlaceholder(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)'
|
||||
}
|
||||
|
||||
function resolveLocationSummaryLabel(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||
}
|
||||
@@ -191,9 +175,28 @@ function normalizeIsoDateValue(value) {
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatExpenseFilledTime(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||||
if (Number.isNaN(candidate.getTime())) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const year = candidate.getFullYear()
|
||||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(candidate.getDate()).padStart(2, '0')
|
||||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
function resolveExpenseUploadHint(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return normalized || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿'
|
||||
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
||||
}
|
||||
|
||||
function extractAttachmentDisplayName(value) {
|
||||
@@ -216,6 +219,12 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||||
const riskText = String(source?.riskText || '').trim()
|
||||
const filledAt = formatExpenseFilledTime(
|
||||
source?.filledAt
|
||||
|| source?.filled_at
|
||||
|| source?.createdAt
|
||||
|| source?.created_at
|
||||
)
|
||||
|
||||
return {
|
||||
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
|
||||
@@ -226,6 +235,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: requestModel?.detailVariant === 'travel' ? `第 ${index + 1} 项` : '业务发生项',
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
@@ -234,7 +244,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
amount: amountDisplay,
|
||||
status: attachments.length ? '已识别' : '待补充',
|
||||
tone: attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
|
||||
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
@@ -372,12 +382,21 @@ function mapIssueToAdvice(issue) {
|
||||
export default {
|
||||
name: 'TravelRequestDetailView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
ConfirmDialog,
|
||||
ReturnReasonDialog
|
||||
},
|
||||
props: {
|
||||
request: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
backLabel: {
|
||||
type: String,
|
||||
default: '返回报销列表'
|
||||
},
|
||||
approvalMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||
@@ -392,10 +411,14 @@ export default {
|
||||
const deletingExpenseId = ref('')
|
||||
const pendingUploadExpenseId = ref('')
|
||||
const submitBusy = ref(false)
|
||||
const submitConfirmDialogOpen = ref(false)
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
const returnBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const approveBusy = ref(false)
|
||||
const approveConfirmDialogOpen = ref(false)
|
||||
const leaderOpinion = ref('')
|
||||
const expenseUploadInput = ref(null)
|
||||
const expenseAttachmentMeta = reactive({})
|
||||
const attachmentPreviewOpen = ref(false)
|
||||
@@ -404,6 +427,7 @@ export default {
|
||||
const attachmentPreviewUrl = ref('')
|
||||
const attachmentPreviewName = ref('')
|
||||
const attachmentPreviewMediaType = ref('')
|
||||
const attachmentPreviewItemId = ref('')
|
||||
const expenseEditor = reactive({
|
||||
itemDate: '',
|
||||
itemType: 'other',
|
||||
@@ -455,13 +479,28 @@ export default {
|
||||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.value)
|
||||
const isDirectManagerApprovalStage = computed(() => {
|
||||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||||
return node === '直属领导审批'
|
||||
})
|
||||
const showLeaderApprovalPanel = computed(() =>
|
||||
Boolean(props.approvalMode)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& isDirectManagerApprovalStage.value
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const canReturnRequest = computed(() =>
|
||||
canManageCurrentClaim.value
|
||||
canReturnExpenseClaims(currentUser.value)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const canApproveRequest = computed(() =>
|
||||
showLeaderApprovalPanel.value
|
||||
&& canReturnExpenseClaims(currentUser.value)
|
||||
)
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
const deleteDialogDescription = computed(() =>
|
||||
@@ -474,6 +513,7 @@ export default {
|
||||
|| submitBusy.value
|
||||
|| deleteBusy.value
|
||||
|| returnBusy.value
|
||||
|| approveBusy.value
|
||||
|| creatingExpense.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
@@ -583,12 +623,8 @@ export default {
|
||||
})
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length))
|
||||
const expenseTableColumnCount = computed(
|
||||
() => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const expenseSummaryText = computed(
|
||||
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const detailNote = computed(
|
||||
() =>
|
||||
@@ -599,7 +635,49 @@ export default {
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
||||
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
|
||||
const attachmentPreviewEntries = computed(() =>
|
||||
expenseItems.value
|
||||
.filter((item) => item.invoiceId)
|
||||
.map((item, index) => ({
|
||||
item,
|
||||
itemId: item.id,
|
||||
index,
|
||||
name: resolveAttachmentDisplayName(item) || `第 ${index + 1} 条附件`,
|
||||
metadata: resolveAttachmentMeta(item)
|
||||
}))
|
||||
)
|
||||
const currentAttachmentPreviewIndex = computed(() =>
|
||||
attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value)
|
||||
)
|
||||
const currentAttachmentPreviewEntry = computed(() => {
|
||||
const index = currentAttachmentPreviewIndex.value
|
||||
return index >= 0 ? attachmentPreviewEntries.value[index] : null
|
||||
})
|
||||
const attachmentPreviewIndexLabel = computed(() => {
|
||||
const currentIndex = currentAttachmentPreviewIndex.value
|
||||
const total = attachmentPreviewEntries.value.length
|
||||
return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : ''
|
||||
})
|
||||
const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1)
|
||||
const currentAttachmentPreviewInsight = computed(() => {
|
||||
const entry = currentAttachmentPreviewEntry.value
|
||||
if (!entry) {
|
||||
return null
|
||||
}
|
||||
|
||||
return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item)
|
||||
})
|
||||
const currentAttachmentPreviewRiskCards = computed(() => {
|
||||
const entry = currentAttachmentPreviewEntry.value
|
||||
if (!entry) {
|
||||
return []
|
||||
}
|
||||
|
||||
return buildAttachmentRiskCards({
|
||||
expenseItems: [entry.item],
|
||||
attachmentMetaByItemId: expenseAttachmentMeta
|
||||
})
|
||||
})
|
||||
|
||||
function applyLocalExpenseItemPatch(itemId, patch) {
|
||||
expenseItems.value = rebuildExpenseItems(
|
||||
@@ -617,36 +695,13 @@ export default {
|
||||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||
}
|
||||
|
||||
function resolveAttachmentPreviewTitle(item) {
|
||||
const fileName = resolveAttachmentDisplayName(item)
|
||||
return fileName ? `预览附件:${fileName}` : '预览附件'
|
||||
}
|
||||
|
||||
function resolveAttachmentRecognition(item) {
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
const documentInfo = metadata?.document_info
|
||||
const requirementCheck = metadata?.requirement_check
|
||||
if (!documentInfo && !requirementCheck) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fields = Array.isArray(documentInfo?.fields)
|
||||
? documentInfo.fields
|
||||
.map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.label && field.value)
|
||||
: []
|
||||
|
||||
return {
|
||||
documentTypeLabel:
|
||||
String(documentInfo?.document_type_label || '').trim()
|
||||
|| resolveDocumentTypeLabel(documentInfo?.document_type),
|
||||
requirementLabel: requirementCheck
|
||||
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
|
||||
: '待校验附件类型',
|
||||
requirementTone: requirementCheck
|
||||
? (requirementCheck.matches ? 'pass' : 'high')
|
||||
: 'medium',
|
||||
message: String(requirementCheck?.message || '').trim(),
|
||||
fields: fields.slice(0, 4).map((field) => `${field.label}:${field.value}`)
|
||||
}
|
||||
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
|
||||
}
|
||||
|
||||
function buildAttachmentRiskNotice(attachment) {
|
||||
@@ -676,7 +731,7 @@ export default {
|
||||
|
||||
function canPreviewAttachment(item) {
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
return Boolean(item.invoiceId && metadata?.previewable)
|
||||
return Boolean(item.invoiceId && metadata?.previewable !== false)
|
||||
}
|
||||
|
||||
function revokeAttachmentPreviewUrl() {
|
||||
@@ -692,6 +747,7 @@ export default {
|
||||
attachmentPreviewError.value = ''
|
||||
attachmentPreviewName.value = ''
|
||||
attachmentPreviewMediaType.value = ''
|
||||
attachmentPreviewItemId.value = ''
|
||||
revokeAttachmentPreviewUrl()
|
||||
}
|
||||
|
||||
@@ -769,42 +825,16 @@ export default {
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
const riskItems = expenseItems.value
|
||||
.map((item, index) => {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
if (!state || !['medium', 'high'].includes(state.tone)) {
|
||||
return ''
|
||||
}
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
|
||||
})
|
||||
|
||||
const adviceText = String(state.suggestion || state.summary || '').trim()
|
||||
const prefix = state.tone === 'high' ? '优先整改' : '继续核对'
|
||||
return `第 ${index + 1} 条附件需${prefix}:${adviceText || '请根据系统提示补充或更换附件。'}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (!completionItems.length && !riskItems.length) {
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items: [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const hasHighRisk = expenseItems.value.some((item) => resolveExpenseRiskState(item)?.tone === 'high')
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待补信息',
|
||||
summary: completionItems.length
|
||||
? '建议先补齐必填信息,再处理附件核验项,完成后即可提交审批。'
|
||||
: '草稿信息已基本齐全,建议先处理附件风险后再提交审批。',
|
||||
items: [...completionItems, ...riskItems]
|
||||
}
|
||||
return buildAiAdviceViewModel({
|
||||
completionItems,
|
||||
riskCards
|
||||
})
|
||||
})
|
||||
|
||||
function startExpenseEdit(item) {
|
||||
@@ -836,12 +866,6 @@ export default {
|
||||
if (isPlaceholderValue(expenseEditor.itemReason)) {
|
||||
return '请输入费用说明。'
|
||||
}
|
||||
if (
|
||||
isLocationRequiredExpenseType(expenseEditor.itemType)
|
||||
&& isPlaceholderValue(expenseEditor.itemLocation)
|
||||
) {
|
||||
return '请输入业务地点。'
|
||||
}
|
||||
|
||||
const amount = Number(expenseEditor.itemAmount)
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
@@ -890,7 +914,12 @@ export default {
|
||||
}
|
||||
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法上传附件。')
|
||||
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -901,22 +930,29 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function openAttachmentPreview(item) {
|
||||
if (!request.value.claimId || !canPreviewAttachment(item)) {
|
||||
async function loadAttachmentPreview(item) {
|
||||
if (!request.value.claimId || !item?.invoiceId) {
|
||||
return
|
||||
}
|
||||
|
||||
closeAttachmentPreview()
|
||||
attachmentPreviewOpen.value = true
|
||||
attachmentPreviewLoading.value = true
|
||||
attachmentPreviewError.value = ''
|
||||
attachmentPreviewItemId.value = item.id
|
||||
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
attachmentPreviewMediaType.value =
|
||||
String(metadata?.preview_kind || '').trim() === 'image'
|
||||
? 'image/png'
|
||||
: String(metadata?.media_type || '').trim()
|
||||
let metadata = resolveAttachmentMeta(item)
|
||||
|
||||
try {
|
||||
if (!metadata) {
|
||||
metadata = await refreshExpenseAttachmentMeta(item.id)
|
||||
}
|
||||
if (metadata?.previewable === false) {
|
||||
throw new Error('当前附件暂不支持直接预览。')
|
||||
}
|
||||
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
||||
attachmentPreviewMediaType.value =
|
||||
String(metadata?.preview_kind || '').trim() === 'image'
|
||||
? 'image/png'
|
||||
: String(metadata?.media_type || '').trim()
|
||||
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
|
||||
revokeAttachmentPreviewUrl()
|
||||
attachmentPreviewUrl.value = URL.createObjectURL(blob)
|
||||
@@ -928,11 +964,48 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function openAttachmentPreview(item) {
|
||||
if (!request.value.claimId || !canPreviewAttachment(item)) {
|
||||
return
|
||||
}
|
||||
|
||||
closeAttachmentPreview()
|
||||
attachmentPreviewOpen.value = true
|
||||
await loadAttachmentPreview(item)
|
||||
}
|
||||
|
||||
async function goToAttachmentPreview(offset) {
|
||||
if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = attachmentPreviewEntries.value
|
||||
const currentIndex = currentAttachmentPreviewIndex.value
|
||||
const nextIndex = (currentIndex + offset + entries.length) % entries.length
|
||||
const nextEntry = entries[nextIndex]
|
||||
if (nextEntry?.item) {
|
||||
await loadAttachmentPreview(nextEntry.item)
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousAttachmentPreview() {
|
||||
void goToAttachmentPreview(-1)
|
||||
}
|
||||
|
||||
function goToNextAttachmentPreview() {
|
||||
void goToAttachmentPreview(1)
|
||||
}
|
||||
|
||||
async function uploadExpenseFile(item, file) {
|
||||
if (!item || !file) {
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
return
|
||||
}
|
||||
|
||||
uploadingExpenseId.value = item.id
|
||||
|
||||
try {
|
||||
@@ -986,7 +1059,9 @@ export default {
|
||||
|
||||
async function handleExpenseFileChange(event) {
|
||||
const target = event?.target
|
||||
const file = target?.files?.[0]
|
||||
const fileList = target?.files
|
||||
const fileCount = fileList?.length || 0
|
||||
const file = fileList?.[0]
|
||||
const itemId = pendingUploadExpenseId.value
|
||||
pendingUploadExpenseId.value = ''
|
||||
|
||||
@@ -994,6 +1069,11 @@ export default {
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
if (fileCount > 1) {
|
||||
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!file || !itemId) {
|
||||
return
|
||||
}
|
||||
@@ -1059,11 +1139,12 @@ export default {
|
||||
savingExpenseId.value = item.id
|
||||
try {
|
||||
const nextInvoiceId = expenseEditor.invoiceId.trim()
|
||||
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
|
||||
await updateExpenseClaimItem(request.value.claimId, item.id, {
|
||||
item_date: expenseEditor.itemDate,
|
||||
item_type: expenseEditor.itemType,
|
||||
item_reason: expenseEditor.itemReason.trim(),
|
||||
item_location: expenseEditor.itemLocation.trim(),
|
||||
item_location: preservedLocation,
|
||||
item_amount: Number(expenseEditor.itemAmount),
|
||||
invoice_id: nextInvoiceId
|
||||
})
|
||||
@@ -1071,7 +1152,7 @@ export default {
|
||||
itemDate: expenseEditor.itemDate,
|
||||
itemType: expenseEditor.itemType,
|
||||
itemReason: expenseEditor.itemReason.trim(),
|
||||
itemLocation: expenseEditor.itemLocation.trim(),
|
||||
itemLocation: preservedLocation,
|
||||
itemAmount: Number(expenseEditor.itemAmount),
|
||||
invoiceId: nextInvoiceId
|
||||
})
|
||||
@@ -1096,7 +1177,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
function handleSubmit() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||||
return
|
||||
@@ -1107,6 +1188,30 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeSubmitConfirmDialog() {
|
||||
if (submitBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmSubmitRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||||
submitConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!canSubmit.value) {
|
||||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||||
submitConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await submitExpenseClaim(request.value.claimId)
|
||||
@@ -1119,6 +1224,7 @@ export default {
|
||||
} else {
|
||||
toast(`${request.value.id} 提交结果已更新。`)
|
||||
}
|
||||
submitConfirmDialogOpen.value = false
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '提交审批失败,请稍后重试。')
|
||||
@@ -1190,7 +1296,7 @@ export default {
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmReturnRequest() {
|
||||
async function confirmReturnRequest(payload) {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
@@ -1198,9 +1304,7 @@ export default {
|
||||
|
||||
returnBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(request.value.claimId, {
|
||||
reason: '详情页退回,请申请人调整后重新提交。'
|
||||
})
|
||||
await returnExpenseClaim(request.value.claimId, payload)
|
||||
returnDialogOpen.value = false
|
||||
toast(`${request.value.id} 已退回待提交。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
@@ -1211,7 +1315,62 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function handleApproveRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!canApproveRequest.value) {
|
||||
toast('当前节点不支持领导审批通过。')
|
||||
return
|
||||
}
|
||||
|
||||
approveConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeApproveConfirmDialog() {
|
||||
if (approveBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
approveConfirmDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmApproveRequest() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
||||
approveConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!canApproveRequest.value) {
|
||||
toast('当前节点不支持领导审批通过。')
|
||||
approveConfirmDialogOpen.value = false
|
||||
return
|
||||
}
|
||||
|
||||
approveBusy.value = true
|
||||
try {
|
||||
await approveExpenseClaim(request.value.claimId, {
|
||||
opinion: leaderOpinion.value.trim()
|
||||
})
|
||||
approveConfirmDialogOpen.value = false
|
||||
leaderOpinion.value = ''
|
||||
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '审批通过失败,请稍后重试。')
|
||||
} finally {
|
||||
approveBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
if (!canOpenAiEntry.value) {
|
||||
return
|
||||
}
|
||||
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
@@ -1229,21 +1388,33 @@ export default {
|
||||
actionBusy,
|
||||
aiAdvice,
|
||||
attachmentPreviewError,
|
||||
attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading,
|
||||
attachmentPreviewMediaType,
|
||||
attachmentPreviewName,
|
||||
attachmentPreviewOpen,
|
||||
attachmentPreviewUrl,
|
||||
approveBusy,
|
||||
approveConfirmDialogOpen,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry,
|
||||
canApproveRequest,
|
||||
canReturnRequest,
|
||||
canSubmit,
|
||||
canPreviewAttachment,
|
||||
closeApproveConfirmDialog,
|
||||
closeDeleteDialog,
|
||||
closeAttachmentPreview,
|
||||
closeSubmitConfirmDialog,
|
||||
closeReturnDialog,
|
||||
confirmApproveRequest,
|
||||
confirmDeleteRequest,
|
||||
confirmSubmitRequest,
|
||||
confirmReturnRequest,
|
||||
currentAttachmentPreviewInsight,
|
||||
currentAttachmentPreviewRiskCards,
|
||||
currentProgressRingMotion,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
@@ -1258,39 +1429,43 @@ export default {
|
||||
creatingExpense,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseSummaryText,
|
||||
expenseTableColumnCount,
|
||||
expenseTotal,
|
||||
expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleAddExpenseItem,
|
||||
handleApproveRequest,
|
||||
handleDeleteRequest,
|
||||
handleExpenseFileChange,
|
||||
handleReturnRequest,
|
||||
handleSubmit,
|
||||
hasExpenseRiskColumn,
|
||||
heroFactItems,
|
||||
isDraftRequest,
|
||||
isEditableRequest,
|
||||
isTravelRequest,
|
||||
locationInputPlaceholder,
|
||||
openAiEntry,
|
||||
openAttachmentPreview,
|
||||
goToNextAttachmentPreview,
|
||||
goToPreviousAttachmentPreview,
|
||||
profile,
|
||||
progressSteps,
|
||||
removeExpenseItem,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
savingExpenseId,
|
||||
showLeaderApprovalPanel,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
submitBusy,
|
||||
submitConfirmDialogOpen,
|
||||
triggerExpenseUpload,
|
||||
uploadedExpenseCount,
|
||||
uploadingExpenseId,
|
||||
|
||||
Reference in New Issue
Block a user