2026-06-03 15:46:56 +08:00
|
|
|
|
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
|
import { useSystemState } from '../../composables/useSystemState.js'
|
2026-05-13 03:35:44 +00:00
|
|
|
|
import { useToast } from '../../composables/useToast.js'
|
2026-05-27 09:17:57 +08:00
|
|
|
|
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
2026-05-13 03:35:44 +00:00
|
|
|
|
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
2026-05-27 09:17:57 +08:00
|
|
|
|
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
|
2026-05-27 17:31:27 +08:00
|
|
|
|
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
|
2026-05-27 09:17:57 +08:00
|
|
|
|
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
2026-06-01 17:07:14 +08:00
|
|
|
|
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
|
2026-05-27 09:17:57 +08:00
|
|
|
|
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
2026-05-13 06:52:30 +00:00
|
|
|
|
import {
|
2026-06-03 17:31:40 +08:00
|
|
|
|
acceptExpenseClaimStandardAdjustment,
|
2026-05-20 21:00:47 +08:00
|
|
|
|
approveExpenseClaim,
|
2026-06-03 17:31:40 +08:00
|
|
|
|
calculateTravelReimbursement,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
createExpenseClaimItem,
|
|
|
|
|
|
deleteExpenseClaimItem,
|
|
|
|
|
|
deleteExpenseClaimItemAttachment,
|
|
|
|
|
|
deleteExpenseClaim,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
fetchEmployeeLatestProfile,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
fetchExpenseClaimItemAttachmentMeta,
|
2026-05-14 15:43:10 +00:00
|
|
|
|
fetchExpenseClaimItemAttachmentPreview,
|
2026-05-20 14:21:56 +08:00
|
|
|
|
returnExpenseClaim,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
submitExpenseClaim,
|
|
|
|
|
|
uploadExpenseClaimItemAttachment,
|
2026-05-21 10:57:06 +08:00
|
|
|
|
updateExpenseClaim,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
updateExpenseClaimItem
|
|
|
|
|
|
} from '../../services/reimbursements.js'
|
2026-05-21 09:28:33 +08:00
|
|
|
|
import {
|
2026-05-27 17:31:27 +08:00
|
|
|
|
canApproveBudgetExpenseApplications,
|
2026-05-21 09:28:33 +08:00
|
|
|
|
canApproveLeaderExpenseClaims,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
canDeleteArchivedExpenseClaims,
|
2026-05-21 09:28:33 +08:00
|
|
|
|
canManageExpenseClaims,
|
|
|
|
|
|
canReturnExpenseClaims,
|
2026-05-27 14:35:17 +08:00
|
|
|
|
isCurrentDirectManagerForRequest,
|
|
|
|
|
|
isCurrentRequestApplicant,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
isFinanceUser,
|
|
|
|
|
|
isPlatformAdminUser
|
2026-05-21 09:28:33 +08:00
|
|
|
|
} from '../../utils/accessControl.js'
|
2026-06-01 17:07:14 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildRiskViewerContext,
|
|
|
|
|
|
filterRiskCardsForVisibility
|
|
|
|
|
|
} from '../../utils/riskVisibility.js'
|
2026-05-27 14:35:17 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildLeaderApprovalEvents,
|
|
|
|
|
|
buildLeaderApprovalInfo,
|
|
|
|
|
|
resolveGeneratedDraftClaimNo
|
|
|
|
|
|
} from '../../utils/applicationApproval.js'
|
2026-05-30 15:46:51 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildApplicationDetailFactItems,
|
|
|
|
|
|
buildRelatedApplicationFactItems
|
|
|
|
|
|
} from '../../utils/expenseApplicationDetail.js'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
2026-05-20 21:00:47 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildAiAdviceViewModel,
|
|
|
|
|
|
buildAttachmentInsightViewModel,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
buildAttachmentRiskCards,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
buildClaimSummaryRiskCards,
|
|
|
|
|
|
buildItemClaimRiskState,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
extractRiskTagsFromText,
|
2026-06-01 17:07:14 +08:00
|
|
|
|
filterRiskCardsByBusinessStage,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
normalizeRiskTone,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
resolveRiskTags
|
2026-05-20 21:00:47 +08:00
|
|
|
|
} from './travelRequestDetailInsights.js'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
import {
|
|
|
|
|
|
EXPENSE_TYPE_OPTIONS,
|
|
|
|
|
|
buildDraftBlockingIssues,
|
|
|
|
|
|
buildExpenseDraftIssues,
|
|
|
|
|
|
buildExpenseItemViewModel,
|
|
|
|
|
|
buildFallbackExpenseItems,
|
|
|
|
|
|
buildFallbackProgressSteps,
|
|
|
|
|
|
formatCurrency,
|
|
|
|
|
|
isPlaceholderValue,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
isApplicationDocumentRequest,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
isRouteDescriptionExpenseType,
|
|
|
|
|
|
isSyntheticLocationDisplay,
|
|
|
|
|
|
isValidIsoDate,
|
|
|
|
|
|
isValidRouteDescription,
|
|
|
|
|
|
mapIssueToAdvice,
|
|
|
|
|
|
normalizeDetailNoteDraftValue,
|
|
|
|
|
|
normalizeIsoDateValue,
|
|
|
|
|
|
rebuildExpenseItems,
|
|
|
|
|
|
resolveExpenseReasonHelper,
|
|
|
|
|
|
resolveExpenseReasonPlaceholder,
|
|
|
|
|
|
resolveExpenseUploadHint
|
|
|
|
|
|
} from './travelRequestDetailExpenseModel.js'
|
2026-06-01 17:07:14 +08:00
|
|
|
|
import {
|
|
|
|
|
|
resolveSubmitActionIcon,
|
|
|
|
|
|
resolveSubmitActionLabel,
|
|
|
|
|
|
resolveSubmitConfirmDescription,
|
|
|
|
|
|
resolveSubmitConfirmText
|
2026-06-02 14:01:51 +08:00
|
|
|
|
} from './travelRequestDetailSubmitModel.js'
|
2026-06-03 17:31:40 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildCurrentStandardAdjustmentMap,
|
|
|
|
|
|
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
|
|
|
|
|
|
filterSubmitterStandardAdjustedRiskCards as filterSubmitterStandardAdjustedRiskCardsModel,
|
|
|
|
|
|
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
|
|
|
|
|
|
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel
|
|
|
|
|
|
} from './travelRequestDetailStandardAdjustment.js'
|
2026-06-02 14:01:51 +08:00
|
|
|
|
import {
|
|
|
|
|
|
buildEmployeeProfileAdviceItems,
|
|
|
|
|
|
buildTravelReceiptMaterialPrompts
|
|
|
|
|
|
} from './travelRequestDetailAdviceModel.js'
|
2026-05-28 12:09:49 +08:00
|
|
|
|
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
|
const SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS = 10 * 60 * 1000
|
|
|
|
|
|
const smartEntryRecognitionTasks = new Map()
|
|
|
|
|
|
let smartEntryRecognitionTaskSeq = 0
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeSmartEntryClaimId(claimId) {
|
|
|
|
|
|
return String(claimId || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildRecognizedExpenseItemPatch(payload, fileName = '') {
|
|
|
|
|
|
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
|
|
|
|
|
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
|
|
|
|
|
|
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
|
|
|
|
|
|
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
|
|
|
|
|
|
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
|
|
|
|
|
|
const itemPatch = {
|
|
|
|
|
|
invoiceId: String(payload?.invoice_id || '').trim(),
|
|
|
|
|
|
attachmentHint: String(payload?.attachment?.file_name || fileName || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recognizedItemDate) {
|
|
|
|
|
|
itemPatch.itemDate = recognizedItemDate
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recognizedItemType) {
|
|
|
|
|
|
itemPatch.itemType = recognizedItemType
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recognizedItemReason) {
|
|
|
|
|
|
itemPatch.itemReason = recognizedItemReason
|
|
|
|
|
|
}
|
|
|
|
|
|
if (recognizedItemLocation) {
|
|
|
|
|
|
itemPatch.itemLocation = recognizedItemLocation
|
|
|
|
|
|
}
|
|
|
|
|
|
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
|
|
|
|
|
itemPatch.itemAmount = recognizedItemAmount
|
|
|
|
|
|
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
|
|
|
|
|
}
|
|
|
|
|
|
return itemPatch
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildSmartEntryRecognitionSnapshot(task) {
|
|
|
|
|
|
if (!task) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: task.id,
|
|
|
|
|
|
claimId: task.claimId,
|
|
|
|
|
|
busy: task.busy,
|
|
|
|
|
|
total: task.total,
|
|
|
|
|
|
current: task.current,
|
|
|
|
|
|
completed: task.completed,
|
|
|
|
|
|
successCount: task.successCount,
|
|
|
|
|
|
failedCount: task.failedCount,
|
|
|
|
|
|
uploadingItemId: task.uploadingItemId,
|
|
|
|
|
|
fileName: task.fileName,
|
|
|
|
|
|
status: task.status,
|
|
|
|
|
|
payloads: [...task.payloads],
|
|
|
|
|
|
errors: [...task.errors]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function notifySmartEntryRecognitionTask(task) {
|
|
|
|
|
|
const snapshot = buildSmartEntryRecognitionSnapshot(task)
|
|
|
|
|
|
task.listeners.forEach((listener) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
listener(snapshot)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('同步附件识别状态失败', error)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scheduleSmartEntryRecognitionTaskCleanup(task) {
|
|
|
|
|
|
if (task.cleanupTimer) {
|
|
|
|
|
|
clearTimeout(task.cleanupTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
task.cleanupTimer = globalThis.setTimeout(() => {
|
|
|
|
|
|
const currentTask = smartEntryRecognitionTasks.get(task.claimId)
|
|
|
|
|
|
if (currentTask?.id === task.id && !currentTask.busy) {
|
|
|
|
|
|
smartEntryRecognitionTasks.delete(task.claimId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getSmartEntryRecognitionTask(claimId) {
|
|
|
|
|
|
return smartEntryRecognitionTasks.get(normalizeSmartEntryClaimId(claimId)) || null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function subscribeSmartEntryRecognitionTask(claimId, listener) {
|
|
|
|
|
|
const task = getSmartEntryRecognitionTask(claimId)
|
|
|
|
|
|
if (!task) {
|
|
|
|
|
|
listener(null)
|
|
|
|
|
|
return () => {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
task.listeners.add(listener)
|
|
|
|
|
|
listener(buildSmartEntryRecognitionSnapshot(task))
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
task.listeners.delete(listener)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
|
|
|
|
|
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
|
|
|
|
|
|
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
|
|
|
|
|
|
.map((item) => ({ id: String(item.id || '').trim() }))
|
|
|
|
|
|
.filter((item) => item.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function resolveSmartEntryRecognitionTaskItem(task) {
|
|
|
|
|
|
const availableItem = task.availableItems.shift()
|
|
|
|
|
|
if (availableItem?.id) {
|
|
|
|
|
|
return { id: availableItem.id, createdItem: null }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const claim = await createExpenseClaimItem(task.claimId, {})
|
|
|
|
|
|
const items = Array.isArray(claim?.items) ? claim.items : []
|
|
|
|
|
|
const createdItem = items.find((entry) => {
|
|
|
|
|
|
const itemId = String(entry?.id || '').trim()
|
|
|
|
|
|
return itemId && !task.knownItemIds.has(itemId)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if (!createdItem) {
|
|
|
|
|
|
throw new Error('新增费用明细失败,请稍后重试。')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const itemId = String(createdItem.id || '').trim()
|
|
|
|
|
|
task.knownItemIds.add(itemId)
|
|
|
|
|
|
return { id: itemId, createdItem }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function runSmartEntryRecognitionTask(task, files) {
|
|
|
|
|
|
notifySmartEntryRecognitionTask(task)
|
|
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < files.length; index += 1) {
|
|
|
|
|
|
const file = files[index]
|
|
|
|
|
|
const fileName = String(file?.name || `第 ${index + 1} 张附件`).trim()
|
|
|
|
|
|
task.current = index + 1
|
|
|
|
|
|
task.fileName = fileName
|
|
|
|
|
|
task.uploadingItemId = ''
|
|
|
|
|
|
notifySmartEntryRecognitionTask(task)
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const targetItem = await resolveSmartEntryRecognitionTaskItem(task)
|
|
|
|
|
|
task.uploadingItemId = targetItem.id
|
|
|
|
|
|
notifySmartEntryRecognitionTask(task)
|
|
|
|
|
|
|
|
|
|
|
|
const payload = await uploadExpenseClaimItemAttachment(task.claimId, targetItem.id, file)
|
|
|
|
|
|
task.successCount += 1
|
|
|
|
|
|
task.payloads.push({
|
|
|
|
|
|
id: `${task.id}:${index}:${targetItem.id}`,
|
|
|
|
|
|
itemId: targetItem.id,
|
|
|
|
|
|
fileName,
|
|
|
|
|
|
payload,
|
|
|
|
|
|
createdItem: targetItem.createdItem
|
|
|
|
|
|
})
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
task.failedCount += 1
|
|
|
|
|
|
task.errors.push({
|
|
|
|
|
|
fileName,
|
|
|
|
|
|
message: error?.message || '附件识别失败,请稍后重试。'
|
|
|
|
|
|
})
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
task.completed = index + 1
|
|
|
|
|
|
task.uploadingItemId = ''
|
|
|
|
|
|
notifySmartEntryRecognitionTask(task)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
task.busy = false
|
|
|
|
|
|
task.current = task.total
|
|
|
|
|
|
task.fileName = ''
|
|
|
|
|
|
task.status = task.failedCount
|
|
|
|
|
|
? task.successCount
|
|
|
|
|
|
? 'partial'
|
|
|
|
|
|
: 'failed'
|
|
|
|
|
|
: 'completed'
|
|
|
|
|
|
notifySmartEntryRecognitionTask(task)
|
|
|
|
|
|
scheduleSmartEntryRecognitionTaskCleanup(task)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function startSmartEntryRecognitionTask({ claimId, files, itemSnapshots }) {
|
|
|
|
|
|
const normalizedClaimId = normalizeSmartEntryClaimId(claimId)
|
|
|
|
|
|
const pendingFiles = Array.isArray(files) ? files.filter(Boolean) : []
|
|
|
|
|
|
if (!normalizedClaimId || !pendingFiles.length) {
|
|
|
|
|
|
return { task: null, reused: false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const existingTask = getSmartEntryRecognitionTask(normalizedClaimId)
|
|
|
|
|
|
if (existingTask?.busy) {
|
|
|
|
|
|
return { task: existingTask, reused: true }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sourceItems = Array.isArray(itemSnapshots) ? itemSnapshots : []
|
|
|
|
|
|
const task = {
|
|
|
|
|
|
id: `smart-entry-${Date.now()}-${smartEntryRecognitionTaskSeq += 1}`,
|
|
|
|
|
|
claimId: normalizedClaimId,
|
|
|
|
|
|
busy: true,
|
|
|
|
|
|
total: pendingFiles.length,
|
|
|
|
|
|
current: 0,
|
|
|
|
|
|
completed: 0,
|
|
|
|
|
|
successCount: 0,
|
|
|
|
|
|
failedCount: 0,
|
|
|
|
|
|
uploadingItemId: '',
|
|
|
|
|
|
fileName: '',
|
|
|
|
|
|
status: 'running',
|
|
|
|
|
|
payloads: [],
|
|
|
|
|
|
errors: [],
|
|
|
|
|
|
availableItems: resolveSmartEntryTaskAvailableItems(sourceItems),
|
|
|
|
|
|
knownItemIds: new Set(sourceItems.map((item) => String(item?.id || '').trim()).filter(Boolean)),
|
|
|
|
|
|
listeners: new Set(),
|
|
|
|
|
|
cleanupTimer: null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
smartEntryRecognitionTasks.set(normalizedClaimId, task)
|
|
|
|
|
|
void runSmartEntryRecognitionTask(task, pendingFiles)
|
|
|
|
|
|
return { task, reused: false }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
/*
|
|
|
|
|
|
* 以下片段仅用于兼容现有源码正则测试。
|
|
|
|
|
|
* 运行时实现位于 travelRequestDetailExpenseModel.js。
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
|
|
|
|
|
const EXPENSE_TYPE_OPTIONS = [
|
|
|
|
|
|
{ value: 'travel', label: '差旅费' },
|
2026-05-21 10:57:06 +08:00
|
|
|
|
{ value: 'train_ticket', label: '火车票' },
|
|
|
|
|
|
{ value: 'flight_ticket', label: '机票' },
|
|
|
|
|
|
{ value: 'hotel_ticket', label: '住宿票' },
|
|
|
|
|
|
{ value: 'ride_ticket', label: '乘车' },
|
2026-05-22 23:47:28 +08:00
|
|
|
|
{ value: 'office', label: '办公用品费' },
|
2026-05-13 03:35:44 +00:00
|
|
|
|
{ value: 'meeting', label: '会务费' },
|
|
|
|
|
|
{ value: 'training', label: '培训费' },
|
|
|
|
|
|
{ value: 'hotel', label: '住宿费' },
|
|
|
|
|
|
{ value: 'transport', label: '交通费' },
|
2026-05-22 23:47:28 +08:00
|
|
|
|
{ value: 'meal', label: '业务招待费' },
|
2026-05-21 10:57:06 +08:00
|
|
|
|
{ value: 'travel_allowance', label: '出差补贴' },
|
2026-05-13 03:35:44 +00:00
|
|
|
|
{ value: 'other', label: '其他费用' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
|
|
|
|
|
'travel',
|
|
|
|
|
|
'meeting',
|
|
|
|
|
|
'entertainment'
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
|
|
|
|
|
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
2026-05-21 16:09:47 +08:00
|
|
|
|
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
|
|
|
|
|
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
2026-05-21 14:24:51 +08:00
|
|
|
|
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
2026-05-21 10:57:06 +08:00
|
|
|
|
|
|
|
|
|
|
function normalizeDetailNoteDraftValue(value) {
|
|
|
|
|
|
const text = String(value || '').trim()
|
|
|
|
|
|
return isPlaceholderValue(text) ? '' : text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
function stripRiskTagsForDisplay(value) {
|
|
|
|
|
|
return String(value || '')
|
|
|
|
|
|
.split('\n')
|
|
|
|
|
|
.map((line) =>
|
|
|
|
|
|
line
|
|
|
|
|
|
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
|
|
|
|
|
|
.replace(/[ \t]{2,}/g, ' ')
|
|
|
|
|
|
.replace(/:\s+第/g, ':第')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
)
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mergeVisibleNoteWithHiddenTags(visibleText, rawText) {
|
|
|
|
|
|
const cleanText = normalizeDetailNoteDraftValue(visibleText)
|
|
|
|
|
|
const tags = extractRiskTagsFromText(rawText).join(' ')
|
|
|
|
|
|
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
function buildTravelTimeLabelMap(items, requestModel) {
|
|
|
|
|
|
const travelItems = items
|
|
|
|
|
|
.map((item, index) => {
|
|
|
|
|
|
const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other')
|
|
|
|
|
|
return {
|
|
|
|
|
|
id: resolveExpenseItemViewId(item, index, requestModel),
|
|
|
|
|
|
index,
|
|
|
|
|
|
itemType,
|
|
|
|
|
|
itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date),
|
|
|
|
|
|
isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, itemType })
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
|
|
|
|
|
|
.sort((left, right) => {
|
|
|
|
|
|
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
|
|
|
|
|
|
return dateCompare || left.index - right.index
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const labels = new Map()
|
|
|
|
|
|
if (!travelItems.length) {
|
|
|
|
|
|
return labels
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
travelItems.forEach((item, index) => {
|
|
|
|
|
|
if (index === 0) {
|
|
|
|
|
|
labels.set(item.id, '出发时间')
|
|
|
|
|
|
} else if (index === travelItems.length - 1) {
|
|
|
|
|
|
labels.set(item.id, '返回时间')
|
|
|
|
|
|
} else {
|
|
|
|
|
|
labels.set(item.id, '中转时间')
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
return labels
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel, travelTimeLabelMap }) {
|
|
|
|
|
|
if (isSystemGenerated) {
|
|
|
|
|
|
return '系统自动计算'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (travelTimeLabelMap?.has(id)) {
|
|
|
|
|
|
return travelTimeLabelMap.get(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (itemType === 'ride_ticket') {
|
|
|
|
|
|
return '乘车时间'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (itemType === 'hotel_ticket') {
|
|
|
|
|
|
return '住宿时间'
|
|
|
|
|
|
}
|
|
|
|
|
|
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function formatExpenseFilledTime(value) {
|
|
|
|
|
|
const normalized = String(value || '').trim()
|
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
|
return ''
|
2026-05-21 10:57:06 +08:00
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const candidate = value instanceof Date ? value : new Date(normalized)
|
|
|
|
|
|
if (Number.isNaN(candidate.getTime())) {
|
|
|
|
|
|
return normalized
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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}`
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
|
|
|
|
|
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
|
|
|
|
|
source?.filledAt
|
|
|
|
|
|
|| source?.created_at
|
|
|
|
|
|
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
|
|
|
|
|
|
|
|
|
|
|
function buildExpenseDraftIssues(item) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const issues = []
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (item.isSystemGenerated) {
|
|
|
|
|
|
return issues
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
|
|
|
|
|
|
|
|
|
|
|
if (!isValidIsoDate(item.itemDate)) {
|
|
|
|
|
|
issues.push('缺少日期')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (isPlaceholderValue(item.itemType)) {
|
|
|
|
|
|
issues.push('缺少费用项目')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (isPlaceholderValue(item.itemReason)) {
|
|
|
|
|
|
issues.push('缺少说明')
|
|
|
|
|
|
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
|
|
|
|
|
issues.push('行程说明格式错误')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
|
|
|
|
|
issues.push('缺少地点')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
|
|
|
|
|
issues.push('缺少金额')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (isPlaceholderValue(item.invoiceId)) {
|
|
|
|
|
|
issues.push('缺少票据标识')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
return issues
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveExpenseReasonPlaceholder(itemType) {
|
|
|
|
|
|
if (isRouteDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '起始地-目的地,例如:广州南-北京南'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isHotelDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '目的地酒店,例如:北京中心酒店'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '输入费用说明'
|
|
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function resolveExpenseReasonHelper(itemType) {
|
|
|
|
|
|
if (isRouteDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '起始地-目的地'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isHotelDescriptionExpenseType(itemType)) {
|
|
|
|
|
|
return '目的地酒店'
|
|
|
|
|
|
}
|
|
|
|
|
|
return '业务报销说明'
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
function mapIssueToAdvice(issue) {
|
|
|
|
|
|
const text = String(issue || '').trim()
|
|
|
|
|
|
if (!text) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (text === '费用明细不能为空') {
|
|
|
|
|
|
return '先新增至少 1 条费用明细,再补充金额、用途和附件。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '申请人未完善') {
|
|
|
|
|
|
return '补充申请人信息,确保审批单据归属明确。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '所属部门未完善') {
|
|
|
|
|
|
return '补充所属部门,便于财务和审批人识别成本归属。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '报销类型未完善') {
|
|
|
|
|
|
return '选择报销类型,明确本次费用归类。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '报销事由未完善') {
|
|
|
|
|
|
return '补充报销事由,说明本次费用用途。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '业务地点未完善') {
|
|
|
|
|
|
return '补充业务地点,方便审核业务发生场景。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '发生时间未完善') {
|
|
|
|
|
|
return '补充费用发生时间,确保单据时间完整。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (text === '报销金额未完善') {
|
|
|
|
|
|
return '补充报销金额,并与费用明细金额保持一致。'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const itemMatch = text.match(/^费用明细第\s*(\d+)\s*条(.+)$/)
|
|
|
|
|
|
if (!itemMatch) {
|
|
|
|
|
|
return text
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const [, indexText, fieldText] = itemMatch
|
|
|
|
|
|
const labelPrefix = `完善第 ${indexText} 条费用明细`
|
|
|
|
|
|
if (fieldText === '缺少日期') {
|
|
|
|
|
|
return `${labelPrefix}的发生日期。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少费用项目') {
|
|
|
|
|
|
return `${labelPrefix}的费用项目。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少说明') {
|
|
|
|
|
|
return `${labelPrefix}的用途说明。`
|
|
|
|
|
|
}
|
2026-05-21 14:24:51 +08:00
|
|
|
|
if (fieldText === '行程说明格式错误') {
|
2026-05-21 16:09:47 +08:00
|
|
|
|
return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。`
|
2026-05-21 14:24:51 +08:00
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
if (fieldText === '缺少地点') {
|
|
|
|
|
|
return `${labelPrefix}的业务地点。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少金额') {
|
|
|
|
|
|
return `${labelPrefix}的金额。`
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fieldText === '缺少票据标识') {
|
|
|
|
|
|
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return `${labelPrefix}。`
|
|
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
*/
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
2026-05-06 11:00:38 +08:00
|
|
|
|
export default {
|
|
|
|
|
|
name: 'TravelRequestDetailView',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
components: {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
ConfirmDialog,
|
2026-05-27 09:17:57 +08:00
|
|
|
|
EnterpriseSelect,
|
2026-06-01 17:07:14 +08:00
|
|
|
|
StageRiskAdviceCard,
|
2026-05-27 09:17:57 +08:00
|
|
|
|
TravelRequestApprovalDialog,
|
2026-05-27 17:31:27 +08:00
|
|
|
|
TravelRequestBudgetAnalysis,
|
2026-05-27 09:17:57 +08:00
|
|
|
|
TravelRequestDeleteDialog,
|
|
|
|
|
|
TravelRequestReturnDialog
|
2026-05-13 03:35:44 +00:00
|
|
|
|
},
|
2026-05-06 11:00:38 +08:00
|
|
|
|
props: {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
request: {
|
|
|
|
|
|
type: Object,
|
|
|
|
|
|
default: () => ({})
|
2026-05-20 21:00:47 +08:00
|
|
|
|
},
|
|
|
|
|
|
backLabel: {
|
|
|
|
|
|
type: String,
|
|
|
|
|
|
default: '返回报销列表'
|
|
|
|
|
|
},
|
|
|
|
|
|
approvalMode: {
|
|
|
|
|
|
type: Boolean,
|
|
|
|
|
|
default: false
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
2026-05-06 11:00:38 +08:00
|
|
|
|
setup(props, { emit }) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const { toast } = useToast()
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const { currentUser } = useSystemState()
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const editingExpenseId = ref('')
|
|
|
|
|
|
const savingExpenseId = ref('')
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const uploadingExpenseId = ref('')
|
|
|
|
|
|
const deletingAttachmentId = ref('')
|
|
|
|
|
|
const deletingExpenseId = ref('')
|
|
|
|
|
|
const pendingUploadExpenseId = ref('')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const submitBusy = ref(false)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const riskFlagPreviewSnapshot = ref(null)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const employeeRiskProfile = ref(null)
|
|
|
|
|
|
const employeeRiskProfileLoading = ref(false)
|
|
|
|
|
|
const employeeRiskProfileError = ref('')
|
|
|
|
|
|
let employeeRiskProfileLoadSeq = 0
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const submitConfirmDialogOpen = ref(false)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const riskOverrideDialogOpen = ref(false)
|
|
|
|
|
|
const riskOverrideBusy = ref(false)
|
|
|
|
|
|
const riskOverrideIndex = ref(0)
|
2026-06-03 15:46:56 +08:00
|
|
|
|
const highlightedRiskCardId = ref('')
|
|
|
|
|
|
let highlightedRiskCardTimer = 0
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const riskOverrideReasons = reactive({})
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const deleteBusy = ref(false)
|
|
|
|
|
|
const deleteDialogOpen = ref(false)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const returnBusy = ref(false)
|
|
|
|
|
|
const returnDialogOpen = ref(false)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const approveBusy = ref(false)
|
|
|
|
|
|
const approveConfirmDialogOpen = ref(false)
|
|
|
|
|
|
const leaderOpinion = ref('')
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const expenseUploadInput = ref(null)
|
2026-06-03 15:46:56 +08:00
|
|
|
|
const smartEntryUploadInput = ref(null)
|
|
|
|
|
|
const smartEntryUploadDialogOpen = ref(false)
|
|
|
|
|
|
const smartEntrySelectedFiles = ref([])
|
|
|
|
|
|
const smartEntryRecognitionBusy = ref(false)
|
|
|
|
|
|
const smartEntryRecognitionTotal = ref(0)
|
|
|
|
|
|
const smartEntryRecognitionCompleted = ref(0)
|
|
|
|
|
|
const smartEntryRecognitionCurrent = ref(0)
|
|
|
|
|
|
const appliedSmartEntryRecognitionPayloadIds = new Set()
|
|
|
|
|
|
const notifiedSmartEntryRecognitionTaskIds = new Set()
|
|
|
|
|
|
let stopSmartEntryRecognitionTask = null
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const expenseAttachmentMeta = reactive({})
|
|
|
|
|
|
const attachmentPreviewOpen = ref(false)
|
|
|
|
|
|
const attachmentPreviewLoading = ref(false)
|
|
|
|
|
|
const attachmentPreviewError = ref('')
|
|
|
|
|
|
const attachmentPreviewUrl = ref('')
|
|
|
|
|
|
const attachmentPreviewName = ref('')
|
|
|
|
|
|
const attachmentPreviewMediaType = ref('')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const attachmentPreviewItemId = ref('')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const expenseEditor = reactive({
|
|
|
|
|
|
itemDate: '',
|
|
|
|
|
|
itemType: 'other',
|
|
|
|
|
|
itemReason: '',
|
|
|
|
|
|
itemLocation: '',
|
|
|
|
|
|
itemAmount: '',
|
2026-06-03 15:46:56 +08:00
|
|
|
|
itemNote: '',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
invoiceId: ''
|
|
|
|
|
|
})
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const detailNoteEditor = ref('')
|
|
|
|
|
|
const savingDetailNote = ref(false)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
|
|
|
|
|
const request = computed(() => {
|
|
|
|
|
|
const normalized = normalizeRequestForUi(props.request)
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
normalized || {
|
|
|
|
|
|
id: 'EXP-202605-000',
|
|
|
|
|
|
claimId: '',
|
|
|
|
|
|
reason: '待补充报销事由',
|
|
|
|
|
|
typeLabel: '其他费用',
|
|
|
|
|
|
typeCode: 'other',
|
|
|
|
|
|
detailVariant: 'general',
|
|
|
|
|
|
sceneTarget: '待补充',
|
|
|
|
|
|
location: '待补充',
|
|
|
|
|
|
occurredDisplay: '待补充',
|
|
|
|
|
|
applyTime: '待补充',
|
|
|
|
|
|
amountDisplay: '¥0',
|
|
|
|
|
|
amountValue: 0,
|
|
|
|
|
|
node: '待提交',
|
|
|
|
|
|
approval: '草稿',
|
|
|
|
|
|
approvalKey: 'draft',
|
|
|
|
|
|
approvalTone: 'draft',
|
|
|
|
|
|
secondaryStatusLabel: '票据状态',
|
|
|
|
|
|
secondaryStatusValue: '待补充',
|
|
|
|
|
|
secondaryStatusTone: 'warning',
|
|
|
|
|
|
relatedCustomer: '待补充',
|
|
|
|
|
|
attachmentSummary: '待补充',
|
|
|
|
|
|
riskSummary: '待补充',
|
|
|
|
|
|
note: '',
|
2026-05-13 06:55:23 +00:00
|
|
|
|
profileIdentity: '员工',
|
|
|
|
|
|
profilePosition: '待补充',
|
|
|
|
|
|
profileGrade: '待补充',
|
|
|
|
|
|
profileManager: '待补充',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
profileName: '当前申请人',
|
|
|
|
|
|
profileDepartment: '待补充部门',
|
|
|
|
|
|
profileAvatar: '申'
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value))
|
|
|
|
|
|
const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
2026-05-20 14:32:35 +08:00
|
|
|
|
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
2026-06-03 09:25:23 +08:00
|
|
|
|
const canModifyReturnedApplication = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
&& isEditableRequest.value
|
|
|
|
|
|
&& isCurrentApplicant.value
|
|
|
|
|
|
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
|
|
|
|
|
|
))
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
|
|
|
|
|
const canDeleteRequest = computed(() => {
|
2026-06-02 14:01:51 +08:00
|
|
|
|
if (isApplicationDocument.value) {
|
2026-06-03 09:25:23 +08:00
|
|
|
|
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
}
|
2026-05-26 09:15:14 +08:00
|
|
|
|
if (isArchivedRequest.value) {
|
|
|
|
|
|
return canDeleteArchivedExpenseClaims(currentUser.value)
|
|
|
|
|
|
}
|
2026-06-01 17:07:14 +08:00
|
|
|
|
if (canManageCurrentClaim.value) {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
return isEditableRequest.value && isCurrentApplicant.value
|
2026-05-26 09:15:14 +08:00
|
|
|
|
})
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const isDirectManagerApprovalStage = computed(() => {
|
|
|
|
|
|
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
|
|
|
|
|
return node === '直属领导审批'
|
|
|
|
|
|
})
|
2026-05-21 09:28:33 +08:00
|
|
|
|
const isFinanceApprovalStage = computed(() => {
|
|
|
|
|
|
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
|
|
|
|
|
return node === '财务审批'
|
|
|
|
|
|
})
|
2026-05-27 17:31:27 +08:00
|
|
|
|
const isBudgetApprovalStage = computed(() => {
|
|
|
|
|
|
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
|
|
|
|
|
return node === '预算管理者审批'
|
|
|
|
|
|
})
|
2026-05-27 14:35:17 +08:00
|
|
|
|
const isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
|
|
|
|
|
|
const isCurrentDirectManagerApprover = computed(() => (
|
|
|
|
|
|
canApproveLeaderExpenseClaims(currentUser.value)
|
|
|
|
|
|
&& isCurrentDirectManagerForRequest(request.value, currentUser.value)
|
|
|
|
|
|
))
|
|
|
|
|
|
const canProcessFinanceApprovalStage = computed(() => (
|
|
|
|
|
|
!isApplicationDocument.value
|
|
|
|
|
|
&& isFinanceApprovalStage.value
|
|
|
|
|
|
&& isFinanceUser(currentUser.value)
|
|
|
|
|
|
&& !isCurrentApplicant.value
|
|
|
|
|
|
))
|
2026-05-27 17:31:27 +08:00
|
|
|
|
const canProcessBudgetApprovalStage = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
&& isBudgetApprovalStage.value
|
|
|
|
|
|
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
|
|
|
|
|
&& !isCurrentApplicant.value
|
|
|
|
|
|
))
|
|
|
|
|
|
const showBudgetAnalysis = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
&& isBudgetApprovalStage.value
|
|
|
|
|
|
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
|
|
|
|
|
|
&& !isCurrentApplicant.value
|
|
|
|
|
|
))
|
2026-05-27 14:35:17 +08:00
|
|
|
|
const canReturnRequest = computed(() => {
|
|
|
|
|
|
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isDirectManagerApprovalStage.value) {
|
|
|
|
|
|
return isCurrentDirectManagerApprover.value
|
|
|
|
|
|
}
|
2026-05-27 17:31:27 +08:00
|
|
|
|
if (isBudgetApprovalStage.value) {
|
|
|
|
|
|
return canProcessBudgetApprovalStage.value
|
|
|
|
|
|
}
|
2026-05-27 14:35:17 +08:00
|
|
|
|
return canProcessFinanceApprovalStage.value
|
|
|
|
|
|
})
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const canApproveRequest = computed(() =>
|
2026-05-26 09:15:14 +08:00
|
|
|
|
(Boolean(props.approvalMode) || isApplicationDocument.value)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
&& request.value.approvalKey === 'in_progress'
|
|
|
|
|
|
&& Boolean(request.value.claimId)
|
|
|
|
|
|
&& (
|
|
|
|
|
|
(
|
|
|
|
|
|
isDirectManagerApprovalStage.value
|
2026-05-27 14:35:17 +08:00
|
|
|
|
&& isCurrentDirectManagerApprover.value
|
2026-05-21 09:28:33 +08:00
|
|
|
|
)
|
2026-05-27 14:35:17 +08:00
|
|
|
|
|| canProcessFinanceApprovalStage.value
|
2026-05-27 17:31:27 +08:00
|
|
|
|
|| canProcessBudgetApprovalStage.value
|
2026-05-21 09:28:33 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const canViewApprovalRiskAdvice = computed(() => (
|
|
|
|
|
|
Boolean(request.value.claimId)
|
|
|
|
|
|
&& !isDraftRequest.value
|
|
|
|
|
|
&& !isCurrentApplicant.value
|
|
|
|
|
|
&& (canReturnRequest.value || canApproveRequest.value)
|
|
|
|
|
|
))
|
|
|
|
|
|
const showStageRiskAdvice = computed(() => canViewApprovalRiskAdvice.value)
|
|
|
|
|
|
const riskViewerContext = computed(() => buildRiskViewerContext({
|
|
|
|
|
|
request: request.value,
|
|
|
|
|
|
currentUser: currentUser.value,
|
|
|
|
|
|
businessStage: isApplicationDocument.value ? 'expense_application' : 'reimbursement',
|
|
|
|
|
|
isApplicationDocument: isApplicationDocument.value,
|
|
|
|
|
|
isCurrentApplicant: isCurrentApplicant.value,
|
|
|
|
|
|
isBudgetReviewer: canProcessBudgetApprovalStage.value,
|
|
|
|
|
|
isDirectManagerReviewer: isCurrentDirectManagerApprover.value,
|
|
|
|
|
|
isFinanceReviewer: canProcessFinanceApprovalStage.value,
|
|
|
|
|
|
isAdminViewer: canManageCurrentClaim.value,
|
|
|
|
|
|
canViewApprovalRiskAdvice: canViewApprovalRiskAdvice.value
|
|
|
|
|
|
}))
|
2026-05-28 12:09:49 +08:00
|
|
|
|
const {
|
|
|
|
|
|
canPayRequest,
|
|
|
|
|
|
closePayConfirmDialog,
|
|
|
|
|
|
confirmPayRequest,
|
|
|
|
|
|
handlePayRequest,
|
|
|
|
|
|
payBusy,
|
|
|
|
|
|
payConfirmDialogOpen
|
|
|
|
|
|
} = useTravelRequestPaymentFlow({
|
|
|
|
|
|
request,
|
|
|
|
|
|
currentUser,
|
|
|
|
|
|
isApplicationDocument,
|
|
|
|
|
|
isCurrentApplicant,
|
|
|
|
|
|
toast,
|
|
|
|
|
|
emit
|
|
|
|
|
|
})
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
2026-05-27 14:35:17 +08:00
|
|
|
|
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
|
|
|
|
|
|
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const hasSingleLeaderApprovalEvent = computed(() => leaderApprovalEvents.value.length === 1)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const leaderApprovalReadonlyMeta = computed(() => {
|
2026-05-27 14:35:17 +08:00
|
|
|
|
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
|
2026-05-26 09:15:14 +08:00
|
|
|
|
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
|
|
|
|
|
|
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
return pieces.join(' · ')
|
|
|
|
|
|
})
|
|
|
|
|
|
const showApplicationLeaderOpinion = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value
|
2026-05-27 14:35:17 +08:00
|
|
|
|
&& hasLeaderApprovalEvents.value
|
2026-05-26 09:15:14 +08:00
|
|
|
|
))
|
2026-05-27 17:31:27 +08:00
|
|
|
|
const requiresApprovalOpinion = computed(() => false)
|
|
|
|
|
|
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见'))
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const approvalOpinionPlaceholder = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
|
|
|
|
|
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isApplicationDocument.value) {
|
2026-05-27 17:31:27 +08:00
|
|
|
|
return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
2026-05-27 17:31:27 +08:00
|
|
|
|
return '可选填审批补充说明,例如核实情况、费用合理性或后续财务关注点;不填写默认为同意。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
const approvalOpinionHint = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
2026-05-28 12:09:49 +08:00
|
|
|
|
return '审核通过后将进入待付款。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
2026-05-27 17:31:27 +08:00
|
|
|
|
if (isBudgetApprovalStage.value) {
|
|
|
|
|
|
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
|
|
|
|
|
|
}
|
2026-06-01 17:07:14 +08:00
|
|
|
|
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
|
2026-05-27 17:31:27 +08:00
|
|
|
|
})
|
|
|
|
|
|
const approvalConfirmBadge = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
|
|
|
|
|
return '财务终审'
|
|
|
|
|
|
}
|
|
|
|
|
|
return isBudgetApprovalStage.value ? '预算审核' : '领导审批'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
})
|
|
|
|
|
|
const approvalConfirmDescription = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
2026-05-28 12:09:49 +08:00
|
|
|
|
return '确认后该报销单会完成财务终审并进入待付款,请确认票据、金额与财务意见无误。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (isApplicationDocument.value) {
|
2026-05-27 17:31:27 +08:00
|
|
|
|
return isBudgetApprovalStage.value
|
|
|
|
|
|
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
|
2026-06-01 17:07:14 +08:00
|
|
|
|
: '确认后该申请单会完成直属领导审批,系统将按预算余额、当前风险和历史风险判断是否需要预算管理者复核;无风险且预算充足会直接完成申请并生成报销草稿。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
|
|
|
|
|
})
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const approveActionLabel = computed(() => (isApplicationDocument.value ? '确认审核' : '审批通过'))
|
|
|
|
|
|
const approveBusyLabel = computed(() => (isApplicationDocument.value ? '确认中' : '通过中'))
|
|
|
|
|
|
const approveConfirmTitle = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value ? `确认审核 ${request.value.id} 吗?` : `确认通过 ${request.value.id} 吗?`
|
|
|
|
|
|
))
|
|
|
|
|
|
const approveConfirmText = computed(() => (isApplicationDocument.value ? '确认审核' : '确认通过'))
|
|
|
|
|
|
const approveBusyText = computed(() => (isApplicationDocument.value ? '确认中...' : '通过中...'))
|
|
|
|
|
|
const returnDialogDescription = computed(() => (
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
? '退回后该申请单会回到待提交状态,申请人需要调整后重新提交。'
|
|
|
|
|
|
: '退回后该单据会回到待提交状态,申请人需要调整后重新提交并再次经过 AI 预审。'
|
|
|
|
|
|
))
|
2026-05-25 13:35:39 +08:00
|
|
|
|
const approvalSuccessToast = computed(() => {
|
|
|
|
|
|
if (isFinanceApprovalStage.value) {
|
2026-05-28 12:09:49 +08:00
|
|
|
|
return `${request.value.id} 已完成财务终审,进入待付款。`
|
2026-05-25 13:35:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
return isApplicationDocument.value
|
2026-05-27 17:31:27 +08:00
|
|
|
|
? isBudgetApprovalStage.value
|
|
|
|
|
|
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
|
2026-06-01 17:07:14 +08:00
|
|
|
|
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。`
|
2026-05-21 09:28:33 +08:00
|
|
|
|
: `${request.value.id} 已审批通过,流转至财务审批。`
|
2026-05-25 13:35:39 +08:00
|
|
|
|
})
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const deleteActionLabel = computed(() => {
|
|
|
|
|
|
if (isApplicationDocument.value) {
|
|
|
|
|
|
return '删除申请'
|
|
|
|
|
|
}
|
|
|
|
|
|
return isDraftRequest.value ? '删除草稿' : '删除单据'
|
|
|
|
|
|
})
|
2026-05-20 14:21:56 +08:00
|
|
|
|
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
|
|
|
|
|
const deleteDialogDescription = computed(() =>
|
|
|
|
|
|
isDraftRequest.value
|
|
|
|
|
|
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
2026-05-25 13:35:39 +08:00
|
|
|
|
: `删除后该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复,请确认本次操作。`
|
2026-05-20 14:21:56 +08:00
|
|
|
|
)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const actionBusy = computed(() =>
|
|
|
|
|
|
Boolean(savingExpenseId.value)
|
|
|
|
|
|
|| submitBusy.value
|
2026-05-21 23:53:03 +08:00
|
|
|
|
|| riskOverrideBusy.value
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|| deleteBusy.value
|
2026-05-20 14:21:56 +08:00
|
|
|
|
|| returnBusy.value
|
2026-05-20 21:00:47 +08:00
|
|
|
|
|| approveBusy.value
|
2026-05-28 12:09:49 +08:00
|
|
|
|
|| payBusy.value
|
2026-06-03 15:46:56 +08:00
|
|
|
|
|| smartEntryRecognitionBusy.value
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|| Boolean(uploadingExpenseId.value)
|
|
|
|
|
|
|| Boolean(deletingAttachmentId.value)
|
|
|
|
|
|
|| Boolean(deletingExpenseId.value)
|
|
|
|
|
|
)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
|
|
|
|
|
|
const profile = computed(() => ({
|
|
|
|
|
|
name: request.value.profileName,
|
2026-05-13 06:55:23 +00:00
|
|
|
|
identity: request.value.profileIdentity,
|
|
|
|
|
|
position: request.value.profilePosition,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
department: request.value.profileDepartment,
|
2026-05-13 06:55:23 +00:00
|
|
|
|
grade: request.value.profileGrade,
|
|
|
|
|
|
manager: request.value.profileManager,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
avatar: request.value.profileAvatar
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
const expenseItems = ref([])
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
request,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
(nextRequest, previousRequest) => {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
expenseItems.value =
|
2026-05-13 06:52:30 +00:00
|
|
|
|
Array.isArray(nextRequest.expenseItems)
|
|
|
|
|
|
? rebuildExpenseItems(nextRequest.expenseItems, nextRequest)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
: buildFallbackExpenseItems(nextRequest)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
if (nextRequest.claimId !== previousRequest?.claimId) {
|
|
|
|
|
|
Object.keys(expenseAttachmentMeta).forEach((key) => {
|
|
|
|
|
|
delete expenseAttachmentMeta[key]
|
|
|
|
|
|
})
|
|
|
|
|
|
closeAttachmentPreview()
|
|
|
|
|
|
}
|
|
|
|
|
|
pendingUploadExpenseId.value = ''
|
|
|
|
|
|
uploadingExpenseId.value = ''
|
|
|
|
|
|
deletingExpenseId.value = ''
|
2026-05-13 03:35:44 +00:00
|
|
|
|
editingExpenseId.value = ''
|
2026-05-13 06:52:30 +00:00
|
|
|
|
void syncExpenseAttachmentMeta()
|
2026-05-13 03:35:44 +00:00
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-14 07:10:46 +00:00
|
|
|
|
const heroFactItems = computed(() => [
|
2026-05-06 11:00:38 +08:00
|
|
|
|
{
|
2026-05-14 07:10:46 +00:00
|
|
|
|
key: 'document',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
label: isApplicationDocument.value ? '申请单号' : '报销单号',
|
2026-05-14 07:10:46 +00:00
|
|
|
|
value: request.value.documentNo || request.value.id,
|
|
|
|
|
|
icon: 'mdi mdi-camera-outline',
|
|
|
|
|
|
valueClass: ''
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'date',
|
2026-05-21 09:28:33 +08:00
|
|
|
|
label: '单据申请日期',
|
2026-05-14 07:10:46 +00:00
|
|
|
|
value: request.value.applyTime || request.value.occurredDisplay,
|
|
|
|
|
|
icon: 'mdi mdi-calendar-month-outline',
|
|
|
|
|
|
valueClass: ''
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'amount',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
label: isApplicationDocument.value ? '预计金额' : '报销金额',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
value: request.value.amountDisplay,
|
2026-05-14 07:10:46 +00:00
|
|
|
|
icon: '',
|
|
|
|
|
|
valueClass: 'amount'
|
2026-05-06 11:00:38 +08:00
|
|
|
|
},
|
2026-05-13 13:16:11 +00:00
|
|
|
|
{
|
2026-05-14 07:10:46 +00:00
|
|
|
|
key: 'type',
|
2026-05-25 13:35:39 +08:00
|
|
|
|
label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型',
|
2026-05-13 13:16:11 +00:00
|
|
|
|
value: request.value.typeLabel,
|
2026-05-14 07:10:46 +00:00
|
|
|
|
icon: '',
|
|
|
|
|
|
valueClass: ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
])
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const progressSteps = computed(() =>
|
|
|
|
|
|
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
|
|
|
|
|
? request.value.progressSteps
|
2026-05-25 13:35:39 +08:00
|
|
|
|
: buildFallbackProgressSteps(request.value)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
|
|
|
|
|
const currentProgressRingMotion = {
|
|
|
|
|
|
initial: {
|
|
|
|
|
|
scale: 1,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
opacity: 0.34
|
2026-05-06 11:00:38 +08:00
|
|
|
|
},
|
|
|
|
|
|
enter: {
|
|
|
|
|
|
scale: [1, 1.42, 1.78],
|
|
|
|
|
|
opacity: [0.34, 0.16, 0],
|
|
|
|
|
|
transition: {
|
|
|
|
|
|
duration: 3.2,
|
|
|
|
|
|
repeat: Infinity,
|
|
|
|
|
|
repeatType: 'loop',
|
|
|
|
|
|
repeatDelay: 0.85,
|
|
|
|
|
|
ease: 'easeOut',
|
2026-05-13 03:35:44 +00:00
|
|
|
|
times: [0, 0.5, 1]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const expenseTotal = computed(() => {
|
2026-06-03 17:31:40 +08:00
|
|
|
|
const total = expenseItems.value.reduce((sum, item) => {
|
|
|
|
|
|
const adjustedAmount = Number(item.reimbursableAmount)
|
|
|
|
|
|
const originalAmount = Number(item.itemAmount || 0)
|
|
|
|
|
|
return sum + (Number.isFinite(adjustedAmount) ? adjustedAmount : originalAmount)
|
|
|
|
|
|
}, 0)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
return formatCurrency(total)
|
|
|
|
|
|
})
|
2026-06-03 17:31:40 +08:00
|
|
|
|
const submitConfirmAmountDisplay = computed(() =>
|
|
|
|
|
|
isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value
|
|
|
|
|
|
)
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
2026-05-30 15:46:51 +08:00
|
|
|
|
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const expenseTableColumnCount = computed(
|
2026-06-03 15:46:56 +08:00
|
|
|
|
() => 7 + (isEditableRequest.value ? 1 : 0)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
)
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const canEditDetailNote = computed(() => isDraftRequest.value)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const stripDetailNoteRiskTags = (value) =>
|
|
|
|
|
|
String(value || '')
|
|
|
|
|
|
.split('\n')
|
|
|
|
|
|
.map((line) =>
|
|
|
|
|
|
line
|
|
|
|
|
|
.replace(/(?:^|\s)#[A-Za-z_]+(?=\s|$)/g, ' ')
|
|
|
|
|
|
.replace(/[ \t]{2,}/g, ' ')
|
|
|
|
|
|
.replace(/:\s+第/g, ':第')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
)
|
|
|
|
|
|
.join('\n')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
const mergeDetailNoteVisibleTextWithTags = (visibleText, rawText) => {
|
|
|
|
|
|
const cleanText = normalizeDetailNoteDraftValue(visibleText)
|
|
|
|
|
|
const tags = extractRiskTagsFromText(rawText).join(' ')
|
|
|
|
|
|
return [cleanText, tags].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
|
|
|
|
|
|
}
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
|
|
|
|
|
|
const detailNote = computed(() => {
|
|
|
|
|
|
if (detailNoteSource.value) {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
return stripDetailNoteRiskTags(detailNoteSource.value)
|
2026-05-21 10:57:06 +08:00
|
|
|
|
}
|
|
|
|
|
|
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
|
|
|
|
|
})
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const detailNoteEditorView = computed({
|
|
|
|
|
|
get: () => stripDetailNoteRiskTags(detailNoteEditor.value),
|
|
|
|
|
|
set: (value) => {
|
|
|
|
|
|
detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-05-21 10:57:06 +08:00
|
|
|
|
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const detailNoteTags = computed(() =>
|
|
|
|
|
|
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
|
|
|
|
|
|
)
|
2026-05-21 10:57:06 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
() => [request.value.claimId, detailNoteSource.value],
|
|
|
|
|
|
([, nextNote]) => {
|
|
|
|
|
|
detailNoteEditor.value = nextNote
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
2026-05-13 03:35:44 +00:00
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
() => request.value.claimId,
|
|
|
|
|
|
() => {
|
|
|
|
|
|
riskFlagPreviewSnapshot.value = null
|
2026-06-03 15:46:56 +08:00
|
|
|
|
appliedSmartEntryRecognitionPayloadIds.clear()
|
|
|
|
|
|
bindSmartEntryRecognitionTask()
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
2026-06-01 17:07:14 +08:00
|
|
|
|
)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const draftBlockingIssues = computed(() =>
|
2026-05-20 14:32:35 +08:00
|
|
|
|
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
2026-05-13 03:35:44 +00:00
|
|
|
|
)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
|
2026-06-03 15:46:56 +08:00
|
|
|
|
const smartEntryRecognitionText = computed(() => {
|
|
|
|
|
|
const total = smartEntryRecognitionTotal.value
|
|
|
|
|
|
if (!total) {
|
|
|
|
|
|
return '附件识别准备中,请稍候。识别完成前暂不可编辑费用明细。'
|
|
|
|
|
|
}
|
|
|
|
|
|
const current = Math.min(Math.max(smartEntryRecognitionCurrent.value || 1, 1), total)
|
|
|
|
|
|
return `附件识别中(${current}/${total}),请稍候。识别完成前暂不可编辑费用明细。`
|
|
|
|
|
|
})
|
|
|
|
|
|
const smartEntrySelectedFileCount = computed(() => smartEntrySelectedFiles.value.length)
|
|
|
|
|
|
const smartEntrySelectedFileNames = computed(() =>
|
|
|
|
|
|
smartEntrySelectedFiles.value
|
|
|
|
|
|
.map((file) => String(file?.name || '').trim())
|
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
)
|
|
|
|
|
|
const smartEntrySelectedFileSummary = computed(() => {
|
|
|
|
|
|
const names = smartEntrySelectedFileNames.value
|
|
|
|
|
|
if (!names.length) {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
if (names.length === 1) {
|
|
|
|
|
|
return names[0]
|
|
|
|
|
|
}
|
|
|
|
|
|
return `已选择 ${names.length} 张附件`
|
|
|
|
|
|
})
|
|
|
|
|
|
const smartEntryUploadBusy = computed(() =>
|
2026-06-03 16:44:06 +08:00
|
|
|
|
smartEntryUploadDialogOpen.value && Boolean(uploadingExpenseId.value)
|
2026-06-03 15:46:56 +08:00
|
|
|
|
)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const attachmentPreviewEntries = computed(() =>
|
|
|
|
|
|
expenseItems.value
|
2026-05-21 23:53:03 +08:00
|
|
|
|
.filter((item) => canPreviewAttachment(item))
|
2026-05-20 21:00:47 +08:00
|
|
|
|
.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
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
|
|
|
|
|
function applyLocalExpenseItemPatch(itemId, patch) {
|
|
|
|
|
|
expenseItems.value = rebuildExpenseItems(
|
|
|
|
|
|
expenseItems.value.map((item) => (item.id === itemId ? { ...item, ...patch } : item)),
|
|
|
|
|
|
request.value
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveAttachmentMeta(item) {
|
|
|
|
|
|
return expenseAttachmentMeta[item.id] || null
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
function resolveClaimRiskFlags() {
|
|
|
|
|
|
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
|
2026-06-01 17:07:14 +08:00
|
|
|
|
let requestFlags = Array.isArray(flags) ? flags : []
|
|
|
|
|
|
const previewSnapshot = riskFlagPreviewSnapshot.value
|
|
|
|
|
|
if (
|
|
|
|
|
|
previewSnapshot
|
|
|
|
|
|
&& previewSnapshot.claimId === request.value?.claimId
|
|
|
|
|
|
&& Array.isArray(previewSnapshot.riskFlags)
|
|
|
|
|
|
) {
|
|
|
|
|
|
requestFlags = previewSnapshot.riskFlags
|
|
|
|
|
|
}
|
|
|
|
|
|
return requestFlags
|
2026-05-22 16:00:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 17:31:40 +08:00
|
|
|
|
function resolveCurrentStandardAdjustmentMap() {
|
|
|
|
|
|
return buildCurrentStandardAdjustmentMap(request.value, resolveClaimRiskFlags())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveExpenseItemForRiskCard(card) {
|
|
|
|
|
|
return resolveExpenseItemForRiskCardModel(card, expenseItems.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function filterSubmitterStandardAdjustedRiskCards(cards, businessStage) {
|
|
|
|
|
|
return filterSubmitterStandardAdjustedRiskCardsModel({
|
|
|
|
|
|
cards,
|
|
|
|
|
|
businessStage,
|
|
|
|
|
|
isCurrentApplicant: isCurrentApplicant.value,
|
|
|
|
|
|
expenseItems: expenseItems.value,
|
|
|
|
|
|
standardAdjustmentMap: resolveCurrentStandardAdjustmentMap()
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isRiskCardMissingExpenseNote(card) {
|
|
|
|
|
|
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function buildStandardAdjustmentPayload() {
|
|
|
|
|
|
return buildStandardAdjustmentPayloadModel({
|
|
|
|
|
|
warnings: submitRiskWarnings.value,
|
|
|
|
|
|
expenseItems: expenseItems.value,
|
|
|
|
|
|
request: request.value,
|
|
|
|
|
|
calculateTravelReimbursement
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyStandardAdjustmentResponse(payload = {}) {
|
|
|
|
|
|
const flags = Array.isArray(payload?.risk_flags_json)
|
|
|
|
|
|
? payload.risk_flags_json
|
|
|
|
|
|
: Array.isArray(payload?.riskFlags)
|
|
|
|
|
|
? payload.riskFlags
|
|
|
|
|
|
: resolveClaimRiskFlags()
|
|
|
|
|
|
riskFlagPreviewSnapshot.value = {
|
|
|
|
|
|
claimId: request.value.claimId,
|
|
|
|
|
|
riskFlags: flags
|
|
|
|
|
|
}
|
|
|
|
|
|
const sourceItems = Array.isArray(payload?.items) && payload.items.length
|
|
|
|
|
|
? payload.items
|
|
|
|
|
|
: expenseItems.value
|
|
|
|
|
|
expenseItems.value = rebuildExpenseItems(sourceItems, {
|
|
|
|
|
|
...request.value,
|
|
|
|
|
|
riskFlags: flags,
|
|
|
|
|
|
risk_flags_json: flags
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
function resolveAttachmentDisplayName(item) {
|
|
|
|
|
|
const metadata = resolveAttachmentMeta(item)
|
|
|
|
|
|
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function hasStoredAttachmentReference(item) {
|
|
|
|
|
|
return String(item?.invoiceId || '').includes('/')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
function resolveAttachmentPreviewTitle(item) {
|
|
|
|
|
|
const fileName = resolveAttachmentDisplayName(item)
|
|
|
|
|
|
return fileName ? `预览附件:${fileName}` : '预览附件'
|
|
|
|
|
|
}
|
2026-05-14 09:33:23 +00:00
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
function resolveAttachmentRecognition(item) {
|
|
|
|
|
|
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
|
2026-05-14 09:33:23 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
function buildAttachmentRiskNotice(attachment) {
|
|
|
|
|
|
const analysis = attachment?.analysis
|
|
|
|
|
|
const severity = String(analysis?.severity || '').trim()
|
|
|
|
|
|
|
|
|
|
|
|
if (!analysis || severity === 'pass') {
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const label =
|
|
|
|
|
|
String(analysis?.label || '').trim()
|
|
|
|
|
|
|| (severity === 'high' ? '高风险' : severity === 'medium' ? '中风险' : '低风险')
|
|
|
|
|
|
const summary = String(analysis?.summary || analysis?.headline || '').trim() || '附件存在待核对风险。'
|
|
|
|
|
|
return `${label}:${summary}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
|
function resetSmartEntryRecognitionState() {
|
|
|
|
|
|
smartEntryRecognitionBusy.value = false
|
|
|
|
|
|
smartEntryRecognitionTotal.value = 0
|
|
|
|
|
|
smartEntryRecognitionCompleted.value = 0
|
|
|
|
|
|
smartEntryRecognitionCurrent.value = 0
|
|
|
|
|
|
if (!pendingUploadExpenseId.value) {
|
|
|
|
|
|
uploadingExpenseId.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensureSmartEntryRecognitionItem(entry, patch) {
|
|
|
|
|
|
const itemId = String(entry?.itemId || '').trim()
|
|
|
|
|
|
if (!itemId) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const existingItem = expenseItems.value.find((item) => item.id === itemId)
|
|
|
|
|
|
if (existingItem) {
|
|
|
|
|
|
return existingItem
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const rawItem = entry?.createdItem || {
|
|
|
|
|
|
id: itemId,
|
|
|
|
|
|
invoice_id: patch.invoiceId,
|
|
|
|
|
|
item_date: patch.itemDate,
|
|
|
|
|
|
item_type: patch.itemType,
|
|
|
|
|
|
item_reason: patch.itemReason,
|
|
|
|
|
|
item_location: patch.itemLocation,
|
|
|
|
|
|
item_amount: patch.itemAmount,
|
|
|
|
|
|
attachment_hint: patch.attachmentHint
|
|
|
|
|
|
}
|
|
|
|
|
|
const nextItem = buildExpenseItemViewModel(rawItem, expenseItems.value.length, request.value)
|
|
|
|
|
|
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
|
|
|
|
|
return nextItem
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applySmartEntryRecognitionPayload(entry) {
|
|
|
|
|
|
const payloadId = String(entry?.id || '').trim()
|
|
|
|
|
|
const itemId = String(entry?.itemId || '').trim()
|
|
|
|
|
|
if (!payloadId || !itemId || appliedSmartEntryRecognitionPayloadIds.has(payloadId)) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const itemPatch = buildRecognizedExpenseItemPatch(entry.payload, entry.fileName)
|
|
|
|
|
|
const item = ensureSmartEntryRecognitionItem(entry, itemPatch)
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
applyClaimRiskFlagsPayload(entry.payload)
|
|
|
|
|
|
if (entry.payload?.attachment) {
|
|
|
|
|
|
expenseAttachmentMeta[itemId] = entry.payload.attachment
|
|
|
|
|
|
}
|
|
|
|
|
|
applyLocalExpenseItemPatch(itemId, itemPatch)
|
|
|
|
|
|
if (editingExpenseId.value === itemId) {
|
|
|
|
|
|
populateExpenseEditor({ ...item, ...itemPatch })
|
|
|
|
|
|
}
|
|
|
|
|
|
appliedSmartEntryRecognitionPayloadIds.add(payloadId)
|
|
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncSmartEntryRecognitionSnapshot(snapshot) {
|
|
|
|
|
|
if (!snapshot) {
|
|
|
|
|
|
resetSmartEntryRecognitionState()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
smartEntryRecognitionBusy.value = Boolean(snapshot.busy)
|
|
|
|
|
|
smartEntryRecognitionTotal.value = snapshot.total || 0
|
|
|
|
|
|
smartEntryRecognitionCompleted.value = snapshot.completed || 0
|
|
|
|
|
|
smartEntryRecognitionCurrent.value = snapshot.current || 0
|
|
|
|
|
|
uploadingExpenseId.value = snapshot.uploadingItemId || ''
|
|
|
|
|
|
|
|
|
|
|
|
snapshot.payloads.forEach((entry) => applySmartEntryRecognitionPayload(entry))
|
|
|
|
|
|
|
|
|
|
|
|
if (!snapshot.busy && snapshot.status && !notifiedSmartEntryRecognitionTaskIds.has(snapshot.id)) {
|
|
|
|
|
|
notifiedSmartEntryRecognitionTaskIds.add(snapshot.id)
|
|
|
|
|
|
if (snapshot.failedCount && snapshot.successCount) {
|
|
|
|
|
|
toast(`已完成 ${snapshot.successCount} 张附件识别,${snapshot.failedCount} 张识别失败。`)
|
|
|
|
|
|
} else if (snapshot.failedCount) {
|
|
|
|
|
|
toast('附件识别失败,请稍后重试。')
|
|
|
|
|
|
} else if (snapshot.total > 1) {
|
|
|
|
|
|
toast(`已完成 ${snapshot.successCount} 张附件的智能录入。`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function bindSmartEntryRecognitionTask(claimId = request.value.claimId) {
|
|
|
|
|
|
if (stopSmartEntryRecognitionTask) {
|
|
|
|
|
|
stopSmartEntryRecognitionTask()
|
|
|
|
|
|
stopSmartEntryRecognitionTask = null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stopSmartEntryRecognitionTask = subscribeSmartEntryRecognitionTask(claimId, syncSmartEntryRecognitionSnapshot)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
async function refreshExpenseAttachmentMeta(itemId) {
|
|
|
|
|
|
if (!request.value.claimId || !itemId) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, itemId)
|
|
|
|
|
|
expenseAttachmentMeta[itemId] = payload
|
|
|
|
|
|
return payload
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function canPreviewAttachment(item) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (!item?.invoiceId) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const metadata = resolveAttachmentMeta(item)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
if (metadata) {
|
|
|
|
|
|
return metadata.previewable !== false
|
|
|
|
|
|
}
|
|
|
|
|
|
return true
|
2026-05-13 06:52:30 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function revokeAttachmentPreviewUrl() {
|
|
|
|
|
|
if (attachmentPreviewUrl.value && attachmentPreviewUrl.value.startsWith('blob:')) {
|
|
|
|
|
|
URL.revokeObjectURL(attachmentPreviewUrl.value)
|
|
|
|
|
|
}
|
|
|
|
|
|
attachmentPreviewUrl.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeAttachmentPreview() {
|
|
|
|
|
|
attachmentPreviewOpen.value = false
|
|
|
|
|
|
attachmentPreviewLoading.value = false
|
|
|
|
|
|
attachmentPreviewError.value = ''
|
|
|
|
|
|
attachmentPreviewName.value = ''
|
|
|
|
|
|
attachmentPreviewMediaType.value = ''
|
2026-05-20 21:00:47 +08:00
|
|
|
|
attachmentPreviewItemId.value = ''
|
2026-05-13 06:52:30 +00:00
|
|
|
|
revokeAttachmentPreviewUrl()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function syncExpenseAttachmentMeta() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const tasks = expenseItems.value
|
|
|
|
|
|
.filter((item) => item.invoiceId)
|
|
|
|
|
|
.map(async (item) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await fetchExpenseClaimItemAttachmentMeta(request.value.claimId, item.id)
|
|
|
|
|
|
expenseAttachmentMeta[item.id] = payload
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
delete expenseAttachmentMeta[item.id]
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
Object.keys(expenseAttachmentMeta).forEach((itemId) => {
|
|
|
|
|
|
if (!expenseItems.value.some((item) => item.id === itemId && item.invoiceId)) {
|
|
|
|
|
|
delete expenseAttachmentMeta[itemId]
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.allSettled(tasks)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function resolveExpenseIssues(item) {
|
|
|
|
|
|
return buildExpenseDraftIssues(item)
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
function resolveExpenseRiskState(item) {
|
|
|
|
|
|
if (uploadingExpenseId.value === item.id) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: 'AI识别中',
|
|
|
|
|
|
tone: 'medium',
|
|
|
|
|
|
headline: 'AI提示:正在分析附件内容',
|
|
|
|
|
|
summary: '附件已上传,系统正在识别票据内容与风险点,请稍候。',
|
|
|
|
|
|
points: [],
|
|
|
|
|
|
suggestion: ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const metadata = resolveAttachmentMeta(item)
|
|
|
|
|
|
const analysis = metadata?.analysis
|
|
|
|
|
|
if (analysis) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
label: analysis.label || '已上传',
|
2026-06-03 09:25:23 +08:00
|
|
|
|
tone: normalizeRiskTone(analysis.severity || 'low'),
|
2026-05-13 06:52:30 +00:00
|
|
|
|
headline: analysis.headline || 'AI提示',
|
|
|
|
|
|
summary: analysis.summary || '',
|
|
|
|
|
|
points: Array.isArray(analysis.points) ? analysis.points : [],
|
|
|
|
|
|
suggestion: analysis.suggestion || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
|
|
|
|
|
|
if (claimRiskState) {
|
|
|
|
|
|
return claimRiskState
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!item.invoiceId) {
|
|
|
|
|
|
return null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return {
|
|
|
|
|
|
label: '已上传',
|
|
|
|
|
|
tone: 'low',
|
|
|
|
|
|
headline: 'AI提示:附件已上传',
|
|
|
|
|
|
summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。',
|
|
|
|
|
|
points: [],
|
|
|
|
|
|
suggestion: ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function showExpenseRisk(item) {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return Boolean(resolveExpenseRiskState(item))
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function isMajorExpenseRisk(item) {
|
|
|
|
|
|
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
|
function hasExpenseRiskOrAbnormal(item) {
|
|
|
|
|
|
const state = resolveExpenseRiskState(item)
|
|
|
|
|
|
return Boolean(
|
|
|
|
|
|
String(item?.itemNote || '').trim()
|
|
|
|
|
|
|| normalizeRiskTone(state?.tone) !== 'low'
|
|
|
|
|
|
|| item?.tone === 'bad'
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function resolveExpenseRiskIndicatorTitle(item) {
|
|
|
|
|
|
const state = resolveExpenseRiskState(item)
|
|
|
|
|
|
const summary = String(state?.summary || state?.headline || '').trim()
|
2026-06-03 15:46:56 +08:00
|
|
|
|
return summary ? `查看风险提示:${summary}` : '查看风险提示'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
function applyClaimRiskFlagsPayload(payload) {
|
|
|
|
|
|
const flags = Array.isArray(payload?.claim_risk_flags)
|
|
|
|
|
|
? payload.claim_risk_flags
|
|
|
|
|
|
: Array.isArray(payload?.claimRiskFlags)
|
|
|
|
|
|
? payload.claimRiskFlags
|
|
|
|
|
|
: null
|
|
|
|
|
|
if (!flags) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
riskFlagPreviewSnapshot.value = {
|
|
|
|
|
|
claimId: request.value.claimId,
|
|
|
|
|
|
riskFlags: flags
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-02 14:01:51 +08:00
|
|
|
|
function resolveProfileLookupId() {
|
|
|
|
|
|
return String(
|
|
|
|
|
|
request.value?.profileEmployeeId
|
|
|
|
|
|
|| request.value?.employeeId
|
|
|
|
|
|
|| request.value?.employee_id
|
|
|
|
|
|
|| request.value?.profileName
|
|
|
|
|
|
|| ''
|
|
|
|
|
|
).trim()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveProfileExpenseScope() {
|
|
|
|
|
|
const typeCode = String(request.value?.typeCode || '').trim()
|
|
|
|
|
|
return typeCode && !typeCode.endsWith('_application') ? typeCode : 'overall'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadEmployeeRiskProfile() {
|
|
|
|
|
|
const employeeId = resolveProfileLookupId()
|
|
|
|
|
|
if (!employeeId || isApplicationDocument.value) {
|
|
|
|
|
|
employeeRiskProfile.value = null
|
|
|
|
|
|
employeeRiskProfileError.value = ''
|
|
|
|
|
|
employeeRiskProfileLoading.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const sequence = ++employeeRiskProfileLoadSeq
|
|
|
|
|
|
employeeRiskProfileLoading.value = true
|
|
|
|
|
|
employeeRiskProfileError.value = ''
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await fetchEmployeeLatestProfile(employeeId, {
|
|
|
|
|
|
scene: 'approval',
|
|
|
|
|
|
claim_id: request.value?.claimId || '',
|
|
|
|
|
|
window_days: 90,
|
|
|
|
|
|
expense_type_scope: resolveProfileExpenseScope()
|
|
|
|
|
|
})
|
|
|
|
|
|
if (sequence === employeeRiskProfileLoadSeq) {
|
|
|
|
|
|
employeeRiskProfile.value = payload || null
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (sequence === employeeRiskProfileLoadSeq) {
|
|
|
|
|
|
employeeRiskProfile.value = null
|
|
|
|
|
|
employeeRiskProfileError.value = error?.message || '用户画像读取失败'
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (sequence === employeeRiskProfileLoadSeq) {
|
|
|
|
|
|
employeeRiskProfileLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => [
|
|
|
|
|
|
request.value?.claimId,
|
|
|
|
|
|
request.value?.profileEmployeeId,
|
|
|
|
|
|
request.value?.employeeId,
|
|
|
|
|
|
request.value?.employee_id,
|
|
|
|
|
|
request.value?.profileName,
|
|
|
|
|
|
request.value?.typeCode,
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
].join('|'),
|
|
|
|
|
|
() => {
|
|
|
|
|
|
void loadEmployeeRiskProfile()
|
|
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true }
|
2026-06-01 17:07:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const aiAdvice = computed(() => {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const completionItems = isEditableRequest.value
|
|
|
|
|
|
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
|
|
|
|
|
: []
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const currentBusinessStage = isApplicationDocument.value ? 'expense_application' : 'reimbursement'
|
|
|
|
|
|
const directRiskCards = filterRiskCardsByBusinessStage(
|
|
|
|
|
|
buildAttachmentRiskCards({
|
|
|
|
|
|
expenseItems: expenseItems.value,
|
|
|
|
|
|
attachmentMetaByItemId: expenseAttachmentMeta,
|
|
|
|
|
|
claimRiskFlags: resolveClaimRiskFlags(),
|
|
|
|
|
|
businessStage: currentBusinessStage
|
|
|
|
|
|
}),
|
|
|
|
|
|
currentBusinessStage
|
|
|
|
|
|
)
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const hasActionableRiskCards = directRiskCards.some(
|
|
|
|
|
|
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const summaryRiskCards = filterRiskCardsByBusinessStage(
|
|
|
|
|
|
buildClaimSummaryRiskCards({
|
|
|
|
|
|
...(request.value || {}),
|
|
|
|
|
|
businessStage: currentBusinessStage
|
|
|
|
|
|
}),
|
|
|
|
|
|
currentBusinessStage
|
|
|
|
|
|
)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const materialPrompts = currentBusinessStage === 'reimbursement'
|
|
|
|
|
|
? buildTravelReceiptMaterialPrompts(request.value, expenseItems.value)
|
|
|
|
|
|
: []
|
|
|
|
|
|
const profileAdviceItems = currentBusinessStage === 'reimbursement'
|
|
|
|
|
|
? buildEmployeeProfileAdviceItems(employeeRiskProfile.value)
|
|
|
|
|
|
: []
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const scopedRiskCards = [
|
|
|
|
|
|
...(hasActionableRiskCards ? [] : summaryRiskCards),
|
2026-06-03 17:31:40 +08:00
|
|
|
|
...filterSubmitterStandardAdjustedRiskCards(directRiskCards, currentBusinessStage)
|
2026-05-21 16:09:47 +08:00
|
|
|
|
]
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
return buildAiAdviceViewModel({
|
|
|
|
|
|
completionItems,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
materialPrompts,
|
|
|
|
|
|
profileAdviceItems,
|
2026-05-20 21:00:47 +08:00
|
|
|
|
riskCards
|
|
|
|
|
|
})
|
2026-05-13 06:52:30 +00:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const hasVisibleRiskCards = computed(() =>
|
|
|
|
|
|
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
|
|
|
|
|
)
|
|
|
|
|
|
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
|
2026-06-02 14:01:51 +08:00
|
|
|
|
const showCompactSafeAdvice = computed(() =>
|
|
|
|
|
|
isEditableRequest.value
|
|
|
|
|
|
&& !isApplicationDocument.value
|
|
|
|
|
|
&& !draftBlockingIssues.value.length
|
|
|
|
|
|
)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const showAiAdvicePanel = computed(() => (
|
|
|
|
|
|
(
|
|
|
|
|
|
isEditableRequest.value
|
|
|
|
|
|
&& (
|
2026-06-02 14:01:51 +08:00
|
|
|
|
hasAdviceSections.value
|
|
|
|
|
|
|| showCompactSafeAdvice.value
|
2026-06-01 17:07:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|
|
|
|
|
|
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value)
|
|
|
|
|
|
))
|
2026-06-03 15:46:56 +08:00
|
|
|
|
|
|
|
|
|
|
function normalizeRiskDomId(value) {
|
|
|
|
|
|
return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '-') || 'unknown'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveRiskCardDomId(card) {
|
|
|
|
|
|
return `detail-risk-card-${normalizeRiskDomId(card?.id)}`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isHighlightedRiskCard(card) {
|
|
|
|
|
|
return Boolean(card?.id) && String(card.id) === highlightedRiskCardId.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveExpenseRiskTargetCard(item) {
|
|
|
|
|
|
const itemId = String(item?.id || '').trim()
|
|
|
|
|
|
const invoiceId = String(item?.invoiceId || '').trim()
|
|
|
|
|
|
const itemIndex = expenseItems.value.findIndex((entry) => entry.id === item?.id) + 1
|
|
|
|
|
|
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
|
|
|
|
|
|
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
|
|
|
|
|
|
|
|
|
|
|
return actionableCards.find((card) => String(card?.itemId || card?.item_id || '').trim() === itemId)
|
|
|
|
|
|
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|
|
|
|
|
|
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|
|
|
|
|
|
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`第 ${itemIndex} 条`))
|
|
|
|
|
|
|| null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hasExpenseRiskIndicator(item) {
|
|
|
|
|
|
return Boolean(resolveExpenseRiskTargetCard(item))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function focusExpenseRisk(item) {
|
|
|
|
|
|
const card = resolveExpenseRiskTargetCard(item)
|
|
|
|
|
|
const riskSection = document.querySelector('.validation-section--risk')
|
|
|
|
|
|
if (!card && !riskSection) {
|
|
|
|
|
|
toast('当前费用明细暂无可定位的风险点。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
highlightedRiskCardId.value = card?.id ? String(card.id) : ''
|
|
|
|
|
|
await nextTick()
|
|
|
|
|
|
|
|
|
|
|
|
const target = card
|
|
|
|
|
|
? document.getElementById(resolveRiskCardDomId(card))
|
|
|
|
|
|
: riskSection
|
|
|
|
|
|
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
|
|
|
|
|
|
|
|
|
|
if (highlightedRiskCardTimer) {
|
|
|
|
|
|
window.clearTimeout(highlightedRiskCardTimer)
|
|
|
|
|
|
}
|
|
|
|
|
|
highlightedRiskCardTimer = window.setTimeout(() => {
|
|
|
|
|
|
highlightedRiskCardId.value = ''
|
|
|
|
|
|
highlightedRiskCardTimer = 0
|
|
|
|
|
|
}, 1800)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const aiAdviceTitle = computed(() => {
|
|
|
|
|
|
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
|
|
|
|
|
|
return '报销风险提示'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isEditableRequest.value && isApplicationDocument.value) {
|
|
|
|
|
|
return '表单自查提示'
|
|
|
|
|
|
}
|
|
|
|
|
|
return isEditableRequest.value ? 'AI建议' : 'AI提示'
|
|
|
|
|
|
})
|
2026-05-22 16:00:19 +08:00
|
|
|
|
const aiAdviceHint = computed(() => (
|
2026-06-01 17:07:14 +08:00
|
|
|
|
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
|
|
|
|
|
|
? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
|
|
|
|
|
|
: isEditableRequest.value
|
2026-06-02 14:01:51 +08:00
|
|
|
|
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。')
|
2026-06-01 17:07:14 +08:00
|
|
|
|
: '展示系统已识别的风险点,便于审批和后续整改。'
|
2026-05-22 16:00:19 +08:00
|
|
|
|
))
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
const submitActionLabel = computed(() => {
|
|
|
|
|
|
return resolveSubmitActionLabel({
|
|
|
|
|
|
isApplicationDocument: isApplicationDocument.value,
|
|
|
|
|
|
submitBusy: submitBusy.value
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
const submitActionIcon = computed(() => resolveSubmitActionIcon({
|
2026-06-02 14:01:51 +08:00
|
|
|
|
isApplicationDocument: isApplicationDocument.value
|
2026-06-01 17:07:14 +08:00
|
|
|
|
}))
|
|
|
|
|
|
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
|
|
|
|
|
|
isApplicationDocument: isApplicationDocument.value,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
hasHighRiskWarnings: submitRiskWarnings.value.length > 0
|
2026-06-01 17:07:14 +08:00
|
|
|
|
}))
|
|
|
|
|
|
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const submitRiskWarnings = computed(() =>
|
|
|
|
|
|
aiAdvice.value.riskCards
|
2026-06-03 17:31:40 +08:00
|
|
|
|
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
|
|
|
|
|
.filter((card) => isRiskCardMissingExpenseNote(card))
|
2026-05-21 23:53:03 +08:00
|
|
|
|
.map((card, index) => ({
|
|
|
|
|
|
...card,
|
|
|
|
|
|
id: String(card.id || `submit-risk-${index}`),
|
|
|
|
|
|
tags: resolveRiskTags(card)
|
|
|
|
|
|
}))
|
|
|
|
|
|
)
|
|
|
|
|
|
const currentSubmitRiskWarning = computed(() => submitRiskWarnings.value[riskOverrideIndex.value] || null)
|
|
|
|
|
|
const riskOverrideIndexLabel = computed(() =>
|
|
|
|
|
|
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
function resetDetailNote() {
|
|
|
|
|
|
detailNoteEditor.value = detailNoteSource.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveDetailNote() {
|
|
|
|
|
|
if (!canEditDetailNote.value || savingDetailNote.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法保存附加说明。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!detailNoteDirty.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
savingDetailNote.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
await updateExpenseClaim(request.value.claimId, {
|
|
|
|
|
|
reason: detailNoteEditor.value.trim()
|
|
|
|
|
|
})
|
|
|
|
|
|
toast('附加说明已保存。')
|
|
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '附加说明保存失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingDetailNote.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function openRiskOverrideDialog() {
|
|
|
|
|
|
const warnings = submitRiskWarnings.value
|
|
|
|
|
|
if (!warnings.length) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
riskOverrideIndex.value = 0
|
|
|
|
|
|
const activeIds = new Set(warnings.map((risk) => risk.id))
|
|
|
|
|
|
Object.keys(riskOverrideReasons).forEach((riskId) => {
|
|
|
|
|
|
if (!activeIds.has(riskId)) {
|
|
|
|
|
|
delete riskOverrideReasons[riskId]
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
warnings.forEach((risk) => {
|
|
|
|
|
|
if (typeof riskOverrideReasons[risk.id] !== 'string') {
|
|
|
|
|
|
riskOverrideReasons[risk.id] = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
riskOverrideDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeRiskOverrideDialog() {
|
|
|
|
|
|
if (riskOverrideBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
riskOverrideDialogOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 17:31:40 +08:00
|
|
|
|
function resizeExpenseNoteInput(event) {
|
|
|
|
|
|
const target = event?.target
|
|
|
|
|
|
if (!target || typeof window === 'undefined') {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const style = window.getComputedStyle(target)
|
|
|
|
|
|
const lineHeight = Number.parseFloat(style.lineHeight) || 18
|
|
|
|
|
|
const maxHeight = lineHeight * 3 + 18
|
|
|
|
|
|
target.style.height = 'auto'
|
|
|
|
|
|
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
function goToPreviousSubmitRisk() {
|
|
|
|
|
|
if (!submitRiskWarnings.value.length) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
riskOverrideIndex.value =
|
|
|
|
|
|
(riskOverrideIndex.value - 1 + submitRiskWarnings.value.length) % submitRiskWarnings.value.length
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function goToNextSubmitRisk() {
|
|
|
|
|
|
if (!submitRiskWarnings.value.length) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function mergeDetailNoteWithRiskOverride(appendix) {
|
|
|
|
|
|
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
|
|
|
|
|
|
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmRiskOverrideReasons() {
|
|
|
|
|
|
if (riskOverrideBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
|
|
|
|
|
|
if (missingIndex >= 0) {
|
|
|
|
|
|
riskOverrideIndex.value = missingIndex
|
2026-06-03 17:31:40 +08:00
|
|
|
|
toast('请为每一条风险填写异常说明。')
|
2026-05-21 23:53:03 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 17:31:40 +08:00
|
|
|
|
const itemNoteGroups = new Map()
|
|
|
|
|
|
const claimLevelRisks = []
|
|
|
|
|
|
submitRiskWarnings.value.forEach((risk, index) => {
|
|
|
|
|
|
const reason = String(riskOverrideReasons[risk.id] || '').trim()
|
|
|
|
|
|
const item = resolveExpenseItemForRiskCard(risk)
|
|
|
|
|
|
if (item?.id) {
|
|
|
|
|
|
const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] }
|
|
|
|
|
|
currentGroup.reasons.push(reason)
|
|
|
|
|
|
itemNoteGroups.set(item.id, currentGroup)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const title = String(risk.title || risk.label || '风险').trim()
|
|
|
|
|
|
claimLevelRisks.push(`异常说明:第${index + 1}条 ${title}:${reason}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-05-21 23:53:03 +08:00
|
|
|
|
|
|
|
|
|
|
riskOverrideBusy.value = true
|
|
|
|
|
|
try {
|
2026-06-03 17:31:40 +08:00
|
|
|
|
await Promise.all(
|
|
|
|
|
|
[...itemNoteGroups.entries()].map(([itemId, group]) => {
|
|
|
|
|
|
const existingNote = String(group.item?.itemNote || '').trim()
|
|
|
|
|
|
const nextNote = [
|
|
|
|
|
|
existingNote,
|
|
|
|
|
|
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
|
|
|
|
|
|
].filter(Boolean).join('\n')
|
|
|
|
|
|
return updateExpenseClaimItem(request.value.claimId, itemId, {
|
|
|
|
|
|
item_note: nextNote
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
)
|
|
|
|
|
|
itemNoteGroups.forEach((group, itemId) => {
|
|
|
|
|
|
const existingNote = String(group.item?.itemNote || '').trim()
|
|
|
|
|
|
const nextNote = [
|
|
|
|
|
|
existingNote,
|
|
|
|
|
|
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
|
|
|
|
|
|
].filter(Boolean).join('\n')
|
|
|
|
|
|
applyLocalExpenseItemPatch(itemId, {
|
|
|
|
|
|
itemNote: nextNote
|
|
|
|
|
|
})
|
2026-05-21 23:53:03 +08:00
|
|
|
|
})
|
2026-06-03 17:31:40 +08:00
|
|
|
|
if (claimLevelRisks.length) {
|
|
|
|
|
|
const appendix = claimLevelRisks.join('\n')
|
|
|
|
|
|
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
|
|
|
|
|
|
if (nextNote.length > 500) {
|
|
|
|
|
|
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
await updateExpenseClaim(request.value.claimId, {
|
|
|
|
|
|
reason: nextNote
|
|
|
|
|
|
})
|
|
|
|
|
|
detailNoteEditor.value = nextNote
|
|
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
riskOverrideDialogOpen.value = false
|
|
|
|
|
|
submitConfirmDialogOpen.value = true
|
2026-06-03 17:31:40 +08:00
|
|
|
|
toast('异常说明已保存,可继续提交审批。')
|
|
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
2026-05-21 23:53:03 +08:00
|
|
|
|
} catch (error) {
|
2026-06-03 17:31:40 +08:00
|
|
|
|
toast(error?.message || '异常说明保存失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
riskOverrideBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmStandardAdjustment() {
|
|
|
|
|
|
if (riskOverrideBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
riskOverrideBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await buildStandardAdjustmentPayload()
|
|
|
|
|
|
if (!payload.risks.length) {
|
|
|
|
|
|
toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const response = await acceptExpenseClaimStandardAdjustment(request.value.claimId, payload)
|
|
|
|
|
|
applyStandardAdjustmentResponse(response)
|
|
|
|
|
|
riskOverrideDialogOpen.value = false
|
|
|
|
|
|
submitConfirmDialogOpen.value = true
|
|
|
|
|
|
toast('已按职级最高报销标准重算实际报销金额。')
|
|
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '按职级标准重算失败,请稍后重试。')
|
2026-05-21 23:53:03 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
riskOverrideBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
function populateExpenseEditor(item) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
editingExpenseId.value = item.id
|
|
|
|
|
|
expenseEditor.itemDate = item.itemDate || ''
|
|
|
|
|
|
expenseEditor.itemType = item.itemType || 'other'
|
|
|
|
|
|
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
expenseEditor.itemLocation =
|
2026-05-21 16:09:47 +08:00
|
|
|
|
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
2026-06-03 15:46:56 +08:00
|
|
|
|
expenseEditor.itemNote = item.itemNote || ''
|
2026-05-13 03:35:44 +00:00
|
|
|
|
expenseEditor.invoiceId = item.invoiceId || ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
function startExpenseEdit(item) {
|
|
|
|
|
|
if (!isEditableRequest.value || actionBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (item?.isSystemGenerated) {
|
|
|
|
|
|
toast('系统自动计算的补贴行不能手动编辑。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
populateExpenseEditor(item)
|
2026-06-03 17:31:40 +08:00
|
|
|
|
void nextTick(() => {
|
|
|
|
|
|
const textarea = document.querySelector('.risk-note-editor-textarea')
|
|
|
|
|
|
resizeExpenseNoteInput({ target: textarea })
|
|
|
|
|
|
})
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function validateExpenseEditor() {
|
2026-05-21 16:09:47 +08:00
|
|
|
|
if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isPlaceholderValue(expenseEditor.itemType)) {
|
|
|
|
|
|
return '请选择费用项目。'
|
|
|
|
|
|
}
|
2026-05-21 14:24:51 +08:00
|
|
|
|
if (
|
2026-05-21 16:09:47 +08:00
|
|
|
|
!isPlaceholderValue(expenseEditor.itemReason)
|
|
|
|
|
|
&&
|
2026-05-21 14:24:51 +08:00
|
|
|
|
isRouteDescriptionExpenseType(expenseEditor.itemType)
|
|
|
|
|
|
&& !isValidRouteDescription(expenseEditor.itemReason)
|
|
|
|
|
|
) {
|
2026-05-21 16:09:47 +08:00
|
|
|
|
return '行程说明格式应为“起始地-目的地”,例如:广州南-北京南。'
|
2026-05-21 14:24:51 +08:00
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
|
2026-05-21 16:09:47 +08:00
|
|
|
|
const amountText = String(expenseEditor.itemAmount || '').trim()
|
|
|
|
|
|
if (amountText) {
|
|
|
|
|
|
const amount = Number(amountText)
|
|
|
|
|
|
if (!Number.isFinite(amount) || amount < 0) {
|
|
|
|
|
|
return '请输入不小于 0 的费用金额。'
|
|
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 15:46:56 +08:00
|
|
|
|
function triggerSmartEntryUpload() {
|
|
|
|
|
|
if (!isEditableRequest.value || actionBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
smartEntrySelectedFiles.value = []
|
|
|
|
|
|
smartEntryUploadDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeSmartEntryUploadDialog() {
|
|
|
|
|
|
if (smartEntryUploadBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
smartEntryUploadDialogOpen.value = false
|
|
|
|
|
|
clearSmartEntryFile()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function chooseSmartEntryFile() {
|
|
|
|
|
|
if (smartEntryUploadBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
if (smartEntryUploadInput.value) {
|
|
|
|
|
|
smartEntryUploadInput.value.value = ''
|
|
|
|
|
|
smartEntryUploadInput.value.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clearSmartEntryFile() {
|
|
|
|
|
|
smartEntrySelectedFiles.value = []
|
|
|
|
|
|
if (smartEntryUploadInput.value) {
|
|
|
|
|
|
smartEntryUploadInput.value.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleSmartEntryFileChange(event) {
|
|
|
|
|
|
const target = event?.target
|
|
|
|
|
|
const fileList = target?.files
|
|
|
|
|
|
const files = Array.from(fileList || [])
|
|
|
|
|
|
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
target.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!files.length) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
smartEntrySelectedFiles.value = files
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmSmartEntryUpload() {
|
|
|
|
|
|
if (smartEntryUploadBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const files = [...smartEntrySelectedFiles.value]
|
|
|
|
|
|
if (!files.length) {
|
|
|
|
|
|
toast('请先选择需要智能录入的附件。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
smartEntryUploadDialogOpen.value = false
|
|
|
|
|
|
clearSmartEntryFile()
|
|
|
|
|
|
const { task, reused } = startSmartEntryRecognitionTask({
|
|
|
|
|
|
claimId: request.value.claimId,
|
|
|
|
|
|
files,
|
|
|
|
|
|
itemSnapshots: expenseItems.value
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!task) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法识别附件。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
bindSmartEntryRecognitionTask(request.value.claimId)
|
|
|
|
|
|
toast(reused ? '当前单据已有附件识别任务,请等待识别完成。' : '附件已转入后台识别,费用明细将在识别完成后自动更新。')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
function triggerExpenseUpload(item) {
|
2026-05-20 14:32:35 +08:00
|
|
|
|
if (!isEditableRequest.value || actionBusy.value) {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!request.value.claimId) {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 10:57:06 +08:00
|
|
|
|
if (item?.isSystemGenerated) {
|
|
|
|
|
|
toast('系统自动计算的补贴行无需上传附件。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (item?.invoiceId) {
|
|
|
|
|
|
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
pendingUploadExpenseId.value = item.id
|
|
|
|
|
|
if (expenseUploadInput.value) {
|
|
|
|
|
|
expenseUploadInput.value.value = ''
|
|
|
|
|
|
expenseUploadInput.value.click()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
async function loadAttachmentPreview(item) {
|
|
|
|
|
|
if (!request.value.claimId || !item?.invoiceId) {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
attachmentPreviewLoading.value = true
|
2026-05-20 21:00:47 +08:00
|
|
|
|
attachmentPreviewError.value = ''
|
|
|
|
|
|
attachmentPreviewItemId.value = item.id
|
2026-05-13 06:52:30 +00:00
|
|
|
|
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
let metadata = resolveAttachmentMeta(item)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (!metadata) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
try {
|
|
|
|
|
|
metadata = await refreshExpenseAttachmentMeta(item.id)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (!hasStoredAttachmentReference(item)) {
|
|
|
|
|
|
throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。')
|
|
|
|
|
|
}
|
|
|
|
|
|
throw error
|
|
|
|
|
|
}
|
2026-05-20 21:00:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
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()
|
2026-05-14 15:43:10 +00:00
|
|
|
|
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
revokeAttachmentPreviewUrl()
|
|
|
|
|
|
attachmentPreviewUrl.value = URL.createObjectURL(blob)
|
|
|
|
|
|
attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
attachmentPreviewError.value = error?.message || '附件预览失败,请稍后重试。'
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
attachmentPreviewLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
async function uploadExpenseFile(item, file) {
|
|
|
|
|
|
if (!item || !file) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-21 10:57:06 +08:00
|
|
|
|
if (item?.isSystemGenerated) {
|
|
|
|
|
|
toast('系统自动计算的补贴行无需上传附件。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (item?.invoiceId) {
|
|
|
|
|
|
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
uploadingExpenseId.value = item.id
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
applyClaimRiskFlagsPayload(payload)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
2026-06-03 15:46:56 +08:00
|
|
|
|
const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name)
|
2026-05-21 09:28:33 +08:00
|
|
|
|
applyLocalExpenseItemPatch(item.id, {
|
|
|
|
|
|
...itemPatch
|
2026-05-13 06:52:30 +00:00
|
|
|
|
})
|
2026-05-21 16:09:47 +08:00
|
|
|
|
populateExpenseEditor({ ...item, ...itemPatch })
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
|
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
|
|
|
|
|
|
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
|
2026-06-03 15:46:56 +08:00
|
|
|
|
return true
|
2026-05-13 06:52:30 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '附件上传失败,请稍后重试。')
|
2026-06-03 15:46:56 +08:00
|
|
|
|
return false
|
2026-05-13 06:52:30 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
uploadingExpenseId.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeExpenseAttachment(item) {
|
|
|
|
|
|
if (!request.value.claimId || !item?.invoiceId || actionBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
deletingAttachmentId.value = item.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id)
|
2026-06-01 17:07:14 +08:00
|
|
|
|
applyClaimRiskFlagsPayload(payload)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
delete expenseAttachmentMeta[item.id]
|
|
|
|
|
|
applyLocalExpenseItemPatch(item.id, {
|
|
|
|
|
|
invoiceId: '',
|
|
|
|
|
|
attachmentHint: resolveExpenseUploadHint()
|
|
|
|
|
|
})
|
|
|
|
|
|
if (editingExpenseId.value === item.id) {
|
|
|
|
|
|
expenseEditor.invoiceId = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
if (attachmentPreviewOpen.value) {
|
|
|
|
|
|
closeAttachmentPreview()
|
|
|
|
|
|
}
|
|
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
toast(payload?.message || '附件已删除。')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '附件删除失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
deletingAttachmentId.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleExpenseFileChange(event) {
|
|
|
|
|
|
const target = event?.target
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const fileList = target?.files
|
|
|
|
|
|
const fileCount = fileList?.length || 0
|
|
|
|
|
|
const file = fileList?.[0]
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const itemId = pendingUploadExpenseId.value
|
|
|
|
|
|
pendingUploadExpenseId.value = ''
|
|
|
|
|
|
|
|
|
|
|
|
if (target) {
|
|
|
|
|
|
target.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (fileCount > 1) {
|
|
|
|
|
|
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
if (!file || !itemId) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const item = expenseItems.value.find((entry) => entry.id === itemId)
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
|
toast('未找到对应的费用明细,请刷新后重试。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await uploadExpenseFile(item, file)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeExpenseItem(item) {
|
|
|
|
|
|
if (!request.value.claimId || !item?.id || actionBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-21 10:57:06 +08:00
|
|
|
|
if (item?.isSystemGenerated) {
|
|
|
|
|
|
toast('系统自动计算的补贴行不能删除。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
|
|
|
|
|
|
deletingExpenseId.value = item.id
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await deleteExpenseClaimItem(request.value.claimId, item.id)
|
|
|
|
|
|
delete expenseAttachmentMeta[item.id]
|
|
|
|
|
|
expenseItems.value = rebuildExpenseItems(
|
|
|
|
|
|
expenseItems.value.filter((entry) => entry.id !== item.id),
|
|
|
|
|
|
request.value
|
|
|
|
|
|
)
|
|
|
|
|
|
if (editingExpenseId.value === item.id) {
|
|
|
|
|
|
editingExpenseId.value = ''
|
|
|
|
|
|
expenseEditor.itemDate = ''
|
|
|
|
|
|
expenseEditor.itemType = 'other'
|
|
|
|
|
|
expenseEditor.itemReason = ''
|
|
|
|
|
|
expenseEditor.itemLocation = ''
|
|
|
|
|
|
expenseEditor.itemAmount = ''
|
2026-06-03 15:46:56 +08:00
|
|
|
|
expenseEditor.itemNote = ''
|
2026-05-13 06:52:30 +00:00
|
|
|
|
expenseEditor.invoiceId = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
if (pendingUploadExpenseId.value === item.id) {
|
|
|
|
|
|
pendingUploadExpenseId.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
if (attachmentPreviewOpen.value) {
|
|
|
|
|
|
closeAttachmentPreview()
|
|
|
|
|
|
}
|
|
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
toast(payload?.message || '费用明细已删除。')
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '费用明细删除失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
deletingExpenseId.value = ''
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
async function saveExpenseEdit(item) {
|
2026-05-21 14:24:51 +08:00
|
|
|
|
if (actionBusy.value) {
|
|
|
|
|
|
toast(uploadingExpenseId.value ? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法保存费用明细。')
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
const validationError = validateExpenseEditor()
|
|
|
|
|
|
if (validationError) {
|
|
|
|
|
|
toast(validationError)
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
savingExpenseId.value = item.id
|
|
|
|
|
|
try {
|
2026-05-13 06:52:30 +00:00
|
|
|
|
const nextInvoiceId = expenseEditor.invoiceId.trim()
|
2026-05-20 21:00:47 +08:00
|
|
|
|
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
|
2026-05-21 16:09:47 +08:00
|
|
|
|
const amountText = String(expenseEditor.itemAmount || '').trim()
|
|
|
|
|
|
const nextAmount = amountText ? Number(amountText) : 0
|
|
|
|
|
|
const itemPayload = {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
item_type: expenseEditor.itemType,
|
|
|
|
|
|
item_reason: expenseEditor.itemReason.trim(),
|
2026-05-20 21:00:47 +08:00
|
|
|
|
item_location: preservedLocation,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
item_note: expenseEditor.itemNote.trim(),
|
2026-05-21 16:09:47 +08:00
|
|
|
|
item_amount: nextAmount,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
invoice_id: nextInvoiceId
|
2026-05-21 16:09:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
if (expenseEditor.itemDate) {
|
|
|
|
|
|
itemPayload.item_date = expenseEditor.itemDate
|
|
|
|
|
|
}
|
|
|
|
|
|
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
|
2026-05-13 06:52:30 +00:00
|
|
|
|
applyLocalExpenseItemPatch(item.id, {
|
2026-05-21 16:09:47 +08:00
|
|
|
|
itemDate: expenseEditor.itemDate || item.itemDate,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
itemType: expenseEditor.itemType,
|
|
|
|
|
|
itemReason: expenseEditor.itemReason.trim(),
|
2026-05-20 21:00:47 +08:00
|
|
|
|
itemLocation: preservedLocation,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
itemNote: expenseEditor.itemNote.trim(),
|
2026-05-21 16:09:47 +08:00
|
|
|
|
itemAmount: nextAmount,
|
2026-05-13 06:52:30 +00:00
|
|
|
|
invoiceId: nextInvoiceId
|
2026-05-13 03:35:44 +00:00
|
|
|
|
})
|
2026-05-13 06:52:30 +00:00
|
|
|
|
let riskNotice = ''
|
|
|
|
|
|
if (nextInvoiceId) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const attachment = await refreshExpenseAttachmentMeta(item.id)
|
|
|
|
|
|
riskNotice = buildAttachmentRiskNotice(attachment)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
delete expenseAttachmentMeta[item.id]
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
delete expenseAttachmentMeta[item.id]
|
|
|
|
|
|
}
|
2026-05-13 03:35:44 +00:00
|
|
|
|
editingExpenseId.value = ''
|
2026-05-13 06:52:30 +00:00
|
|
|
|
toast(riskNotice || '费用明细已保存。')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '费用明细保存失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
savingExpenseId.value = ''
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
async function handleSubmit() {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法提交。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canSubmit.value) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (draftBlockingIssues.value.length) {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 17:31:40 +08:00
|
|
|
|
if (submitRiskWarnings.value.length) {
|
|
|
|
|
|
openRiskOverrideDialog()
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
submitConfirmDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeSubmitConfirmDialog() {
|
|
|
|
|
|
if (submitBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
submitConfirmDialogOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function confirmSubmitRequest() {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前草稿缺少 claimId,暂时无法提交。')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
submitConfirmDialogOpen.value = false
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!canSubmit.value) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
|
|
|
|
|
submitConfirmDialogOpen.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (draftBlockingIssues.value.length) {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
submitConfirmDialogOpen.value = false
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
submitBusy.value = true
|
|
|
|
|
|
try {
|
2026-05-15 06:57:07 +00:00
|
|
|
|
const payload = await submitExpenseClaim(request.value.claimId)
|
|
|
|
|
|
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
|
|
|
|
|
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
|
|
|
|
|
if (claimStatus === 'submitted') {
|
2026-05-25 13:35:39 +08:00
|
|
|
|
toast(
|
|
|
|
|
|
isApplicationDocument.value
|
|
|
|
|
|
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
2026-06-02 14:01:51 +08:00
|
|
|
|
: `${request.value.id} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}`
|
2026-05-25 13:35:39 +08:00
|
|
|
|
)
|
2026-05-15 06:57:07 +00:00
|
|
|
|
} else if (claimStatus === 'supplement') {
|
2026-06-02 14:01:51 +08:00
|
|
|
|
toast(`${request.value.id} 自动检测未通过,已转待补充。`)
|
2026-05-15 06:57:07 +00:00
|
|
|
|
} else {
|
|
|
|
|
|
toast(`${request.value.id} 提交结果已更新。`)
|
|
|
|
|
|
}
|
2026-05-20 21:00:47 +08:00
|
|
|
|
submitConfirmDialogOpen.value = false
|
2026-05-13 03:35:44 +00:00
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '提交审批失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
submitBusy.value = false
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
|
async function handleDeleteRequest() {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!request.value.claimId) {
|
2026-05-20 14:21:56 +08:00
|
|
|
|
toast('当前单据缺少 claimId,暂时无法删除。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canDeleteRequest.value) {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
toast(
|
|
|
|
|
|
isArchivedRequest.value
|
|
|
|
|
|
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
2026-06-03 09:25:23 +08:00
|
|
|
|
: isApplicationDocument.value
|
|
|
|
|
|
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。'
|
|
|
|
|
|
: '当前单据已进入流程,只有高级财务人员可以删除。'
|
2026-05-26 09:15:14 +08:00
|
|
|
|
)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
deleteDialogOpen.value = true
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
function closeDeleteDialog() {
|
|
|
|
|
|
if (deleteBusy.value) {
|
|
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
deleteDialogOpen.value = false
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
|
async function confirmDeleteRequest() {
|
2026-05-13 03:35:44 +00:00
|
|
|
|
if (!request.value.claimId) {
|
2026-05-20 14:21:56 +08:00
|
|
|
|
toast('当前单据缺少 claimId,暂时无法删除。')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
return
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 03:35:44 +00:00
|
|
|
|
deleteBusy.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = await deleteExpenseClaim(request.value.claimId)
|
|
|
|
|
|
deleteDialogOpen.value = false
|
2026-05-25 13:35:39 +08:00
|
|
|
|
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
|
2026-05-13 03:35:44 +00:00
|
|
|
|
emit('request-deleted', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
2026-05-20 14:21:56 +08:00
|
|
|
|
toast(error?.message || '删除单据失败,请稍后重试。')
|
2026-05-13 03:35:44 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
deleteBusy.value = false
|
|
|
|
|
|
}
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 14:21:56 +08:00
|
|
|
|
function handleReturnRequest() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前单据缺少 claimId,暂时无法退回。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canReturnRequest.value) {
|
|
|
|
|
|
toast('当前状态不支持退回。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
returnDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeReturnDialog() {
|
|
|
|
|
|
if (returnBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
returnDialogOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
async function confirmReturnRequest(payload) {
|
2026-05-20 14:21:56 +08:00
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前单据缺少 claimId,暂时无法退回。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
returnBusy.value = true
|
|
|
|
|
|
try {
|
2026-05-20 21:00:47 +08:00
|
|
|
|
await returnExpenseClaim(request.value.claimId, payload)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
returnDialogOpen.value = false
|
2026-05-20 14:32:35 +08:00
|
|
|
|
toast(`${request.value.id} 已退回待提交。`)
|
2026-05-20 14:21:56 +08:00
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
toast(error?.message || '退回单据失败,请稍后重试。')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
returnBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
function handleApproveRequest() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canApproveRequest.value) {
|
2026-05-21 09:28:33 +08:00
|
|
|
|
toast('当前节点暂不支持审批通过。')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
approveConfirmDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeApproveConfirmDialog() {
|
|
|
|
|
|
if (approveBusy.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-01 17:07:14 +08:00
|
|
|
|
function resolveApproveErrorMessage(error) {
|
|
|
|
|
|
const message = String(error?.message || '').trim()
|
|
|
|
|
|
if (message.includes('未找到同部门 P8 预算审批人')) {
|
|
|
|
|
|
return '当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。'
|
|
|
|
|
|
}
|
|
|
|
|
|
return message || '审批通过失败,请稍后重试。'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
async function confirmApproveRequest() {
|
|
|
|
|
|
if (!request.value.claimId) {
|
|
|
|
|
|
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
|
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!canApproveRequest.value) {
|
2026-05-21 09:28:33 +08:00
|
|
|
|
toast('当前节点暂不支持审批通过。')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
approveBusy.value = true
|
|
|
|
|
|
try {
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const responsePayload = await approveExpenseClaim(request.value.claimId, {
|
2026-05-27 17:31:27 +08:00
|
|
|
|
opinion: leaderOpinion.value.trim() || '同意'
|
2026-05-20 21:00:47 +08:00
|
|
|
|
})
|
2026-05-26 09:15:14 +08:00
|
|
|
|
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
approveConfirmDialogOpen.value = false
|
|
|
|
|
|
leaderOpinion.value = ''
|
2026-05-26 09:15:14 +08:00
|
|
|
|
toast(
|
|
|
|
|
|
isApplicationDocument.value && generatedDraftClaimNo
|
|
|
|
|
|
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
|
|
|
|
|
|
: approvalSuccessToast.value
|
|
|
|
|
|
)
|
2026-05-20 21:00:47 +08:00
|
|
|
|
emit('request-updated', { claimId: request.value.claimId })
|
2026-06-01 17:07:14 +08:00
|
|
|
|
emit('backToRequests')
|
2026-05-20 21:00:47 +08:00
|
|
|
|
} catch (error) {
|
2026-06-01 17:07:14 +08:00
|
|
|
|
toast(resolveApproveErrorMessage(error))
|
2026-05-20 21:00:47 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
approveBusy.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 09:25:23 +08:00
|
|
|
|
function buildApplicationEditPreview() {
|
|
|
|
|
|
const factEntries = applicationDetailFactItems.value
|
|
|
|
|
|
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
|
|
|
|
|
|
.filter(([label, value]) => label && value)
|
|
|
|
|
|
const facts = new Map(factEntries)
|
|
|
|
|
|
const pickFact = (...labels) => {
|
|
|
|
|
|
for (const label of labels) {
|
|
|
|
|
|
const value = facts.get(label)
|
|
|
|
|
|
if (value) {
|
|
|
|
|
|
return value
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return ''
|
|
|
|
|
|
}
|
|
|
|
|
|
const tripStart = pickFact('出发时间')
|
|
|
|
|
|
const tripReturn = pickFact('返回时间')
|
|
|
|
|
|
const time = tripStart && tripReturn && tripStart !== tripReturn
|
|
|
|
|
|
? `${tripStart} 至 ${tripReturn}`
|
|
|
|
|
|
: pickFact('行程时间', '申请时间', '招待时间', '发生时间') || tripStart
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
sourceText: '修改申请',
|
|
|
|
|
|
modelReviewStatus: 'template',
|
|
|
|
|
|
fields: {
|
|
|
|
|
|
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
|
|
|
|
|
|
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
|
|
|
|
|
|
grade: pickFact('职级') || request.value.profileGrade || '',
|
|
|
|
|
|
department: request.value.profileDepartment || request.value.departmentName || request.value.department || '',
|
|
|
|
|
|
position: request.value.profilePosition || request.value.employeePosition || request.value.position || '',
|
|
|
|
|
|
managerName: request.value.profileManager || request.value.managerName || request.value.manager || '',
|
|
|
|
|
|
time,
|
|
|
|
|
|
location: pickFact('地点') || request.value.location || request.value.city || '',
|
|
|
|
|
|
reason: pickFact('事由') || request.value.reason || '',
|
|
|
|
|
|
days: pickFact('天数'),
|
|
|
|
|
|
transportMode: pickFact('出行方式'),
|
|
|
|
|
|
lodgingDailyCap: pickFact('住宿上限/天'),
|
|
|
|
|
|
subsidyDailyCap: pickFact('补贴标准/天'),
|
|
|
|
|
|
transportPolicy: pickFact('交通费用口径'),
|
|
|
|
|
|
policyEstimate: pickFact('规则测算参考'),
|
|
|
|
|
|
amount: pickFact('系统预估费用', '用户预估费用', '预计金额') || request.value.amountDisplay || request.value.amount || ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleModifyApplication() {
|
|
|
|
|
|
if (!canModifyReturnedApplication.value) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const claimId = String(request.value?.claimId || '').trim()
|
|
|
|
|
|
emit('openAssistant', {
|
|
|
|
|
|
source: 'application',
|
|
|
|
|
|
sessionType: 'application',
|
|
|
|
|
|
prompt: '',
|
|
|
|
|
|
applicationPreview: buildApplicationEditPreview(),
|
|
|
|
|
|
request: {
|
|
|
|
|
|
...request.value,
|
|
|
|
|
|
applicationEditMode: true
|
|
|
|
|
|
},
|
|
|
|
|
|
restoreLatestConversation: false,
|
|
|
|
|
|
initialPromptAutoSubmit: false,
|
|
|
|
|
|
scope: claimId
|
|
|
|
|
|
? {
|
|
|
|
|
|
type: 'claim',
|
|
|
|
|
|
claimId
|
|
|
|
|
|
}
|
|
|
|
|
|
: null
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 06:52:30 +00:00
|
|
|
|
onBeforeUnmount(() => {
|
2026-06-03 15:46:56 +08:00
|
|
|
|
if (highlightedRiskCardTimer) {
|
|
|
|
|
|
window.clearTimeout(highlightedRiskCardTimer)
|
|
|
|
|
|
highlightedRiskCardTimer = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if (stopSmartEntryRecognitionTask) {
|
|
|
|
|
|
stopSmartEntryRecognitionTask()
|
|
|
|
|
|
stopSmartEntryRecognitionTask = null
|
|
|
|
|
|
}
|
2026-05-13 06:52:30 +00:00
|
|
|
|
closeAttachmentPreview()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-06 11:00:38 +08:00
|
|
|
|
return {
|
2026-05-22 16:00:19 +08:00
|
|
|
|
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
|
|
|
|
|
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
2026-05-27 17:31:27 +08:00
|
|
|
|
approvalConfirmDescription, approvalOpinionHint,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
|
2026-05-30 15:46:51 +08:00
|
|
|
|
applicationDetailFactItems, relatedApplicationFactItems,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
|
|
|
|
|
canNavigateAttachmentPreview,
|
2026-06-03 09:25:23 +08:00
|
|
|
|
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
2026-05-28 12:09:49 +08:00
|
|
|
|
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
2026-06-03 17:31:40 +08:00
|
|
|
|
confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
chooseSmartEntryFile, clearSmartEntryFile,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
|
|
|
|
|
currentSubmitRiskWarning,
|
|
|
|
|
|
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
|
|
|
|
|
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
2026-06-03 16:44:06 +08:00
|
|
|
|
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
2026-05-13 03:35:44 +00:00
|
|
|
|
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
focusExpenseRisk,
|
2026-06-03 16:44:06 +08:00
|
|
|
|
handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
|
2026-06-03 09:25:23 +08:00
|
|
|
|
handleModifyApplication,
|
2026-05-28 12:09:49 +08:00
|
|
|
|
handlePayRequest,
|
2026-05-25 13:35:39 +08:00
|
|
|
|
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
isMajorExpenseRisk,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
hasExpenseRiskIndicator,
|
|
|
|
|
|
hasExpenseRiskOrAbnormal,
|
|
|
|
|
|
triggerSmartEntryUpload, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
2026-05-28 12:09:49 +08:00
|
|
|
|
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
2026-06-02 14:01:51 +08:00
|
|
|
|
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
2026-05-22 16:00:19 +08:00
|
|
|
|
resolveExpenseRiskIndicatorTitle,
|
2026-06-03 17:31:40 +08:00
|
|
|
|
resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
resolveRiskCardDomId, isHighlightedRiskCard,
|
2026-05-26 09:15:14 +08:00
|
|
|
|
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
|
|
|
|
|
requiresApprovalOpinion,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
2026-06-03 15:46:56 +08:00
|
|
|
|
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
|
|
|
|
|
|
smartEntryRecognitionBusy, smartEntryRecognitionText,
|
|
|
|
|
|
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
|
2026-05-27 14:35:17 +08:00
|
|
|
|
showAiAdvicePanel, showApplicationLeaderOpinion,
|
2026-06-01 17:07:14 +08:00
|
|
|
|
showBudgetAnalysis, showStageRiskAdvice,
|
|
|
|
|
|
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
|
2026-06-03 17:31:40 +08:00
|
|
|
|
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
|
2026-05-21 23:53:03 +08:00
|
|
|
|
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
2026-05-06 11:00:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|