- 新增 requestProgressViewer,申请单在直属领导审批视角下将当前步骤展示为'等待批复',travel-request-detail/app-shell/useRequests 接入 - TravelRequestApprovalDialog 增强审批交互,TableLoadingState 补充表格加载占位,ConfirmDialog 扩展确认对话框能力 - useAppShell/useRequests/AppShellRouteView 配套适配申请详情跳转与会话状态 - 同步更新 requestProgressSteps、travel-request-detail-leader-approval、assistant-session-draft-delete、documents-center-status-filter、app-shell-financial-assistant-entry、request-progress-viewer 等测试
2887 lines
104 KiB
JavaScript
2887 lines
104 KiB
JavaScript
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||
import { ElDatePicker } from 'element-plus/es/components/date-picker/index.mjs'
|
||
import { ElInput } from 'element-plus/es/components/input/index.mjs'
|
||
|
||
import { useSystemState } from '../../composables/useSystemState.js'
|
||
import { useToast } from '../../composables/useToast.js'
|
||
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
|
||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
|
||
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
|
||
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
||
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
|
||
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
||
import {
|
||
acceptExpenseClaimStandardAdjustment,
|
||
approveExpenseClaim,
|
||
calculateTravelReimbursement,
|
||
createExpenseClaimItem,
|
||
deleteExpenseClaimItem,
|
||
deleteExpenseClaimItemAttachment,
|
||
deleteExpenseClaim,
|
||
fetchEmployeeLatestProfile,
|
||
fetchExpenseClaimItemAttachmentMeta,
|
||
fetchExpenseClaimItemAttachmentPreview,
|
||
returnExpenseClaim,
|
||
submitExpenseClaim,
|
||
uploadExpenseClaimItemAttachment,
|
||
updateExpenseClaim,
|
||
updateExpenseClaimItem
|
||
} from '../../services/reimbursements.js'
|
||
import {
|
||
canApproveBudgetExpenseApplications,
|
||
canApproveLeaderExpenseClaims,
|
||
canManageExpenseClaims,
|
||
canReturnExpenseClaims,
|
||
isCurrentDirectManagerForRequest,
|
||
isCurrentRequestApplicant,
|
||
isFinanceUser,
|
||
isPlatformAdminUser
|
||
} from '../../utils/accessControl.js'
|
||
import {
|
||
buildRiskViewerContext,
|
||
filterRiskCardsForVisibility
|
||
} from '../../utils/riskVisibility.js'
|
||
import {
|
||
buildLeaderApprovalEvents,
|
||
buildLeaderApprovalInfo,
|
||
resolveGeneratedDraftClaimNo
|
||
} from '../../utils/applicationApproval.js'
|
||
import {
|
||
buildApplicationDetailFactItems,
|
||
buildRelatedApplicationFactItems
|
||
} from '../../utils/expenseApplicationDetail.js'
|
||
import { resolveProgressStepsForViewer } from '../../utils/requestProgressViewer.js'
|
||
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||
import {
|
||
buildAiAdviceViewModel,
|
||
buildAttachmentInsightViewModel,
|
||
buildAttachmentRiskCards,
|
||
buildClaimSummaryRiskCards,
|
||
buildItemClaimRiskState,
|
||
extractRiskTagsFromText,
|
||
filterRiskCardsByBusinessStage,
|
||
normalizeRiskTone,
|
||
resolveRiskTags
|
||
} from './travelRequestDetailInsights.js'
|
||
import {
|
||
EXPENSE_TYPE_OPTIONS,
|
||
buildDraftBlockingIssues,
|
||
buildExpenseDraftIssues,
|
||
buildExpenseItemViewModel,
|
||
buildFallbackExpenseItems,
|
||
buildFallbackProgressSteps,
|
||
formatCurrency,
|
||
isPlaceholderValue,
|
||
isApplicationDocumentRequest,
|
||
isRouteDescriptionExpenseType,
|
||
isSyntheticLocationDisplay,
|
||
isValidIsoDate,
|
||
isValidRouteDescription,
|
||
mapIssueToAdvice,
|
||
normalizeDetailNoteDraftValue,
|
||
normalizeIsoDateValue,
|
||
rebuildExpenseItems,
|
||
resolveExpenseReasonHelper,
|
||
resolveExpenseReasonPlaceholder,
|
||
resolveExpenseUploadHint
|
||
} from './travelRequestDetailExpenseModel.js'
|
||
import {
|
||
resolveSubmitActionIcon,
|
||
resolveSubmitActionLabel,
|
||
resolveSubmitConfirmDescription,
|
||
resolveSubmitConfirmText
|
||
} from './travelRequestDetailSubmitModel.js'
|
||
import {
|
||
buildCurrentStandardAdjustmentMap,
|
||
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
|
||
filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel,
|
||
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
|
||
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel,
|
||
resolveExpenseItemsForRiskCard as resolveExpenseItemsForRiskCardModel
|
||
} from './travelRequestDetailStandardAdjustment.js'
|
||
import {
|
||
buildEmployeeProfileAdviceItems,
|
||
buildTravelReceiptMaterialPrompts
|
||
} from './travelRequestDetailAdviceModel.js'
|
||
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
|
||
|
||
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 }
|
||
}
|
||
|
||
/*
|
||
* 以下片段仅用于兼容现有源码正则测试。
|
||
* 运行时实现位于 travelRequestDetailExpenseModel.js。
|
||
|
||
const EXPENSE_TYPE_OPTIONS = [
|
||
{ value: 'travel', label: '差旅费' },
|
||
{ value: 'train_ticket', label: '火车票' },
|
||
{ value: 'flight_ticket', label: '机票' },
|
||
{ value: 'hotel_ticket', label: '住宿票' },
|
||
{ value: 'ride_ticket', label: '乘车' },
|
||
{ value: 'office', label: '办公用品费' },
|
||
{ value: 'meeting', label: '会务费' },
|
||
{ value: 'training', label: '培训费' },
|
||
{ value: 'hotel', label: '住宿费' },
|
||
{ value: 'transport', label: '交通费' },
|
||
{ value: 'meal', label: '业务招待费' },
|
||
{ value: 'travel_allowance', label: '出差补贴' },
|
||
{ value: 'other', label: '其他费用' }
|
||
]
|
||
|
||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||
'travel',
|
||
'meeting',
|
||
'entertainment'
|
||
])
|
||
|
||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||
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'])
|
||
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
||
|
||
function normalizeDetailNoteDraftValue(value) {
|
||
const text = String(value || '').trim()
|
||
return isPlaceholderValue(text) ? '' : text
|
||
}
|
||
|
||
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')
|
||
}
|
||
|
||
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' ? '出行时间' : '业务发生时间'
|
||
}
|
||
|
||
function formatExpenseFilledTime(value) {
|
||
const normalized = String(value || '').trim()
|
||
if (!normalized) {
|
||
return ''
|
||
}
|
||
|
||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||
if (Number.isNaN(candidate.getTime())) {
|
||
return normalized
|
||
}
|
||
|
||
const year = candidate.getFullYear()
|
||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||
const day = String(candidate.getDate()).padStart(2, '0')
|
||
const hours = String(candidate.getHours()).padStart(2, '0')
|
||
const minutes = String(candidate.getMinutes()).padStart(2, '0')
|
||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||
}
|
||
|
||
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||
source?.filledAt
|
||
|| source?.created_at
|
||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||
|
||
function buildExpenseDraftIssues(item) {
|
||
const issues = []
|
||
if (item.isSystemGenerated) {
|
||
return issues
|
||
}
|
||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||
|
||
if (!isValidIsoDate(item.itemDate)) {
|
||
issues.push('缺少日期')
|
||
}
|
||
if (isPlaceholderValue(item.itemType)) {
|
||
issues.push('缺少费用项目')
|
||
}
|
||
if (isPlaceholderValue(item.itemReason)) {
|
||
issues.push('缺少说明')
|
||
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
|
||
issues.push('行程说明格式错误')
|
||
}
|
||
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
|
||
issues.push('缺少地点')
|
||
}
|
||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||
issues.push('缺少金额')
|
||
}
|
||
if (isPlaceholderValue(item.invoiceId)) {
|
||
issues.push('缺少票据标识')
|
||
}
|
||
|
||
return issues
|
||
}
|
||
|
||
function resolveExpenseReasonPlaceholder(itemType) {
|
||
if (isRouteDescriptionExpenseType(itemType)) {
|
||
return '起始地-目的地,例如:广州南-北京南'
|
||
}
|
||
if (isHotelDescriptionExpenseType(itemType)) {
|
||
return '目的地酒店,例如:北京中心酒店'
|
||
}
|
||
return '输入费用说明'
|
||
}
|
||
|
||
function resolveExpenseReasonHelper(itemType) {
|
||
if (isRouteDescriptionExpenseType(itemType)) {
|
||
return '起始地-目的地'
|
||
}
|
||
if (isHotelDescriptionExpenseType(itemType)) {
|
||
return '目的地酒店'
|
||
}
|
||
return '业务报销说明'
|
||
}
|
||
|
||
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}的用途说明。`
|
||
}
|
||
if (fieldText === '行程说明格式错误') {
|
||
return `${labelPrefix}的行程说明,格式应为“起始地-目的地”。`
|
||
}
|
||
if (fieldText === '缺少地点') {
|
||
return `${labelPrefix}的业务地点。`
|
||
}
|
||
if (fieldText === '缺少金额') {
|
||
return `${labelPrefix}的金额。`
|
||
}
|
||
if (fieldText === '缺少票据标识') {
|
||
return `为第 ${indexText} 条费用明细上传或关联票据附件。`
|
||
}
|
||
|
||
return `${labelPrefix}。`
|
||
}
|
||
*/
|
||
|
||
export default {
|
||
name: 'TravelRequestDetailView',
|
||
components: {
|
||
ConfirmDialog,
|
||
ElDatePicker,
|
||
ElInput,
|
||
EnterpriseSelect,
|
||
StageRiskAdviceCard,
|
||
TravelRequestApprovalDialog,
|
||
TravelRequestBudgetAnalysis,
|
||
TravelRequestDeleteDialog,
|
||
TravelRequestReturnDialog
|
||
},
|
||
props: {
|
||
request: {
|
||
type: Object,
|
||
default: () => ({})
|
||
},
|
||
backLabel: {
|
||
type: String,
|
||
default: '返回报销列表'
|
||
},
|
||
approvalMode: {
|
||
type: Boolean,
|
||
default: false
|
||
}
|
||
},
|
||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||
setup(props, { emit }) {
|
||
const { toast } = useToast()
|
||
const { currentUser } = useSystemState()
|
||
const editingExpenseId = ref('')
|
||
const savingExpenseId = ref('')
|
||
const uploadingExpenseId = ref('')
|
||
const deletingAttachmentId = ref('')
|
||
const deletingExpenseId = ref('')
|
||
const pendingUploadExpenseId = ref('')
|
||
const submitBusy = ref(false)
|
||
const riskFlagPreviewSnapshot = ref(null)
|
||
const employeeRiskProfile = ref(null)
|
||
const employeeRiskProfileLoading = ref(false)
|
||
const employeeRiskProfileError = ref('')
|
||
let employeeRiskProfileLoadSeq = 0
|
||
const submitConfirmDialogOpen = ref(false)
|
||
const riskOverrideDialogOpen = ref(false)
|
||
const riskOverrideBusy = ref(false)
|
||
const standardAdjustmentBusy = ref(false)
|
||
const riskOverrideIndex = ref(0)
|
||
const highlightedRiskCardId = ref('')
|
||
let highlightedRiskCardTimer = 0
|
||
const deleteBusy = ref(false)
|
||
const deleteDialogOpen = ref(false)
|
||
const returnBusy = ref(false)
|
||
const returnDialogOpen = ref(false)
|
||
const approveBusy = ref(false)
|
||
const approveConfirmDialogOpen = ref(false)
|
||
const approvalRiskConfirmed = ref(false)
|
||
const leaderOpinion = ref('')
|
||
const expenseUploadInput = ref(null)
|
||
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
|
||
const expenseAttachmentMeta = reactive({})
|
||
const attachmentPreviewOpen = ref(false)
|
||
const attachmentPreviewLoading = ref(false)
|
||
const attachmentPreviewError = ref('')
|
||
const attachmentPreviewUrl = ref('')
|
||
const attachmentPreviewName = ref('')
|
||
const attachmentPreviewMediaType = ref('')
|
||
const attachmentPreviewItemId = ref('')
|
||
const expenseEditor = reactive({
|
||
itemDate: '',
|
||
itemType: 'other',
|
||
itemReason: '',
|
||
itemLocation: '',
|
||
itemAmount: '',
|
||
itemNote: '',
|
||
invoiceId: ''
|
||
})
|
||
const detailNoteEditor = ref('')
|
||
const savingDetailNote = ref(false)
|
||
let standardAdjustmentTaskSeq = 0
|
||
let submitTaskSeq = 0
|
||
|
||
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: '',
|
||
profileIdentity: '员工',
|
||
profilePosition: '待补充',
|
||
profileGrade: '待补充',
|
||
profileManager: '待补充',
|
||
profileName: '当前申请人',
|
||
profileDepartment: '待补充部门',
|
||
profileAvatar: '申'
|
||
}
|
||
)
|
||
})
|
||
|
||
const isApplicationDocument = computed(() => isApplicationDocumentRequest(request.value))
|
||
const isTravelRequest = computed(() => request.value.detailVariant === 'travel' && !isApplicationDocument.value)
|
||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||
const canModifyReturnedApplication = computed(() => (
|
||
isApplicationDocument.value
|
||
&& isEditableRequest.value
|
||
&& isCurrentApplicant.value
|
||
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
|
||
))
|
||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
||
const isApplicantDeletableRequest = computed(() => {
|
||
if (!isCurrentApplicant.value) {
|
||
return false
|
||
}
|
||
const status = String(request.value.status || request.value.approvalKey || '').trim().toLowerCase()
|
||
return ['draft', 'supplement', 'returned'].includes(status)
|
||
})
|
||
const canDeleteRequest = computed(() => {
|
||
if (isPlatformAdminUser(currentUser.value)) {
|
||
return true
|
||
}
|
||
return isApplicantDeletableRequest.value
|
||
})
|
||
const isDirectManagerApprovalStage = computed(() => {
|
||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||
return node === '直属领导审批'
|
||
})
|
||
const isFinanceApprovalStage = computed(() => {
|
||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||
return node === '财务审批'
|
||
})
|
||
const isBudgetApprovalStage = computed(() => {
|
||
const node = String(request.value.node || request.value.approvalStage || '').trim()
|
||
return node === '预算管理者审批'
|
||
})
|
||
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
|
||
))
|
||
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
|
||
))
|
||
const canProcessCurrentApprovalStage = computed(() => {
|
||
if (isDirectManagerApprovalStage.value) {
|
||
return isCurrentDirectManagerApprover.value
|
||
}
|
||
if (isBudgetApprovalStage.value) {
|
||
return canProcessBudgetApprovalStage.value
|
||
}
|
||
return canProcessFinanceApprovalStage.value
|
||
})
|
||
const canReturnRequest = computed(() => {
|
||
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
|
||
return false
|
||
}
|
||
return canProcessCurrentApprovalStage.value
|
||
})
|
||
const canApproveRequest = computed(() =>
|
||
request.value.approvalKey === 'in_progress'
|
||
&& Boolean(request.value.claimId)
|
||
&& canProcessCurrentApprovalStage.value
|
||
)
|
||
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
|
||
}))
|
||
const {
|
||
canPayRequest,
|
||
closePayConfirmDialog,
|
||
confirmPayRequest,
|
||
handlePayRequest,
|
||
payBusy,
|
||
payConfirmDialogOpen
|
||
} = useTravelRequestPaymentFlow({
|
||
request,
|
||
currentUser,
|
||
isApplicationDocument,
|
||
isCurrentApplicant,
|
||
toast,
|
||
emit
|
||
})
|
||
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
||
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
|
||
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
|
||
const hasSingleLeaderApprovalEvent = computed(() => leaderApprovalEvents.value.length === 1)
|
||
const leaderApprovalReadonlyMeta = computed(() => {
|
||
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
|
||
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
|
||
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
|
||
}
|
||
return pieces.join(' · ')
|
||
})
|
||
const showApplicationLeaderOpinion = computed(() => (
|
||
isApplicationDocument.value
|
||
&& hasLeaderApprovalEvents.value
|
||
))
|
||
const budgetApprovalOpinionRequired = computed(() => (
|
||
isBudgetApprovalStage.value
|
||
&& hasBudgetApprovalWarning(request.value)
|
||
))
|
||
const requiresApprovalOpinion = computed(() => budgetApprovalOpinionRequired.value)
|
||
const approvalOpinionTitle = computed(() => {
|
||
if (isFinanceApprovalStage.value) {
|
||
return '财务意见'
|
||
}
|
||
if (isBudgetApprovalStage.value) {
|
||
return '预算审批意见'
|
||
}
|
||
return '附加意见'
|
||
})
|
||
const approvalOpinionPlaceholder = computed(() => {
|
||
if (isFinanceApprovalStage.value) {
|
||
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
||
}
|
||
if (budgetApprovalOpinionRequired.value) {
|
||
return '预算已超过警戒值,请写明预算审批意见、通过依据或后续控制要求。'
|
||
}
|
||
if (isBudgetApprovalStage.value) {
|
||
return '可选填预算审批补充说明;未超过预算警戒值时不填写默认为同意。'
|
||
}
|
||
if (isApplicationDocument.value) {
|
||
return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
|
||
}
|
||
return '可选填审批补充说明,例如核实情况、费用合理性或后续财务关注点;不填写默认为同意。'
|
||
})
|
||
const approvalOpinionHint = computed(() => {
|
||
if (isFinanceApprovalStage.value) {
|
||
return '审核通过后将进入待付款。'
|
||
}
|
||
if (isBudgetApprovalStage.value) {
|
||
return budgetApprovalOpinionRequired.value
|
||
? '预算已超过警戒值,需填写预算审批意见后才能通过。'
|
||
: '未超过预算警戒值时不填写意见将默认同意,确认后按流程继续流转。'
|
||
}
|
||
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
|
||
})
|
||
const approvalRiskConfirmItems = computed(() =>
|
||
aiAdvice.value.riskCards
|
||
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||
.slice(0, 4)
|
||
.map((card, index) => ({
|
||
id: String(card?.id || `approval-risk-${index + 1}`),
|
||
tone: normalizeRiskTone(card?.tone),
|
||
label: normalizeRiskTone(card?.tone) === 'high' ? '高风险' : '中风险',
|
||
title: String(card?.title || card?.label || '风险提示').trim(),
|
||
description: String(
|
||
card?.relatedExplanationSummary
|
||
|| card?.risk
|
||
|| card?.summary
|
||
|| card?.suggestion
|
||
|| '请核对该风险点对应的说明和佐证材料。'
|
||
).trim()
|
||
}))
|
||
)
|
||
const approvalRiskConfirmRequired = computed(() =>
|
||
canApproveRequest.value
|
||
&& canViewApprovalRiskAdvice.value
|
||
&& approvalRiskConfirmItems.value.length > 0
|
||
)
|
||
const approvalConfirmBadge = computed(() => {
|
||
if (isFinanceApprovalStage.value) {
|
||
return '财务终审'
|
||
}
|
||
return isBudgetApprovalStage.value ? '预算审核' : '领导审批'
|
||
})
|
||
const approvalConfirmDescription = computed(() => {
|
||
if (isFinanceApprovalStage.value) {
|
||
return '确认后该报销单会完成财务终审并进入待付款,请确认票据、金额与财务意见无误。'
|
||
}
|
||
if (isApplicationDocument.value) {
|
||
return isBudgetApprovalStage.value
|
||
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
|
||
: '确认后该申请单会完成直属领导审批,系统将按预算余额、当前风险和历史风险判断是否需要预算管理者复核;无风险且预算充足会直接完成申请并生成报销草稿。'
|
||
}
|
||
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||
})
|
||
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 预审。'
|
||
))
|
||
const approvalSuccessToast = computed(() => {
|
||
if (isFinanceApprovalStage.value) {
|
||
return `${request.value.id} 已完成财务终审,进入待付款。`
|
||
}
|
||
return isApplicationDocument.value
|
||
? isBudgetApprovalStage.value
|
||
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
|
||
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。`
|
||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||
})
|
||
const deleteActionLabel = computed(() => {
|
||
if (isApplicationDocument.value) {
|
||
return '删除申请'
|
||
}
|
||
return isDraftRequest.value ? '删除草稿' : '删除单据'
|
||
})
|
||
const deleteDialogTarget = computed(() => request.value.documentNo || request.value.id || '当前单据')
|
||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value}吗?`)
|
||
const deleteDialogDescription = computed(() =>
|
||
isDraftRequest.value
|
||
? `${deleteDialogTarget.value} 删除后,该草稿及其当前费用明细将不可恢复。`
|
||
: `${deleteDialogTarget.value} 删除后,该${isApplicationDocument.value ? '申请单' : '报销单'}及费用明细将不可恢复。`
|
||
)
|
||
const actionBusy = computed(() =>
|
||
Boolean(savingExpenseId.value)
|
||
|| submitBusy.value
|
||
|| deleteBusy.value
|
||
|| returnBusy.value
|
||
|| approveBusy.value
|
||
|| payBusy.value
|
||
|| smartEntryRecognitionBusy.value
|
||
|| Boolean(uploadingExpenseId.value)
|
||
|| Boolean(deletingAttachmentId.value)
|
||
|| Boolean(deletingExpenseId.value)
|
||
)
|
||
|
||
const profile = computed(() => ({
|
||
name: request.value.profileName,
|
||
identity: request.value.profileIdentity,
|
||
position: request.value.profilePosition,
|
||
department: request.value.profileDepartment,
|
||
grade: request.value.profileGrade,
|
||
manager: request.value.profileManager,
|
||
avatar: request.value.profileAvatar
|
||
}))
|
||
|
||
const expenseItems = ref([])
|
||
|
||
watch(
|
||
request,
|
||
(nextRequest, previousRequest) => {
|
||
expenseItems.value =
|
||
Array.isArray(nextRequest.expenseItems)
|
||
? rebuildExpenseItems(nextRequest.expenseItems, nextRequest)
|
||
: buildFallbackExpenseItems(nextRequest)
|
||
if (nextRequest.claimId !== previousRequest?.claimId) {
|
||
Object.keys(expenseAttachmentMeta).forEach((key) => {
|
||
delete expenseAttachmentMeta[key]
|
||
})
|
||
standardAdjustmentTaskSeq += 1
|
||
standardAdjustmentBusy.value = false
|
||
submitTaskSeq += 1
|
||
submitBusy.value = false
|
||
closeAttachmentPreview()
|
||
}
|
||
pendingUploadExpenseId.value = ''
|
||
uploadingExpenseId.value = ''
|
||
deletingExpenseId.value = ''
|
||
editingExpenseId.value = ''
|
||
void syncExpenseAttachmentMeta()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
const heroFactItems = computed(() => [
|
||
{
|
||
key: 'document',
|
||
label: isApplicationDocument.value ? '申请单号' : '报销单号',
|
||
value: request.value.documentNo || request.value.id,
|
||
icon: 'mdi mdi-camera-outline',
|
||
valueClass: ''
|
||
},
|
||
{
|
||
key: 'date',
|
||
label: '单据申请日期',
|
||
value: request.value.applyTime || request.value.occurredDisplay,
|
||
icon: 'mdi mdi-calendar-month-outline',
|
||
valueClass: ''
|
||
},
|
||
{
|
||
key: 'amount',
|
||
label: isApplicationDocument.value ? '预计金额' : '报销金额',
|
||
value: request.value.amountDisplay,
|
||
icon: '',
|
||
valueClass: 'amount'
|
||
},
|
||
{
|
||
key: 'type',
|
||
label: isApplicationDocument.value ? '申请类型' : isTravelRequest.value ? '差旅类型' : '报销类型',
|
||
value: request.value.typeLabel,
|
||
icon: '',
|
||
valueClass: ''
|
||
}
|
||
])
|
||
|
||
const progressSteps = computed(() => {
|
||
const sourceSteps = Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
||
? request.value.progressSteps
|
||
: buildFallbackProgressSteps(request.value)
|
||
return resolveProgressStepsForViewer(sourceSteps, {
|
||
isApplicationDocument: isApplicationDocument.value,
|
||
isCurrentDirectManagerApprover: isCurrentDirectManagerApprover.value
|
||
})
|
||
})
|
||
|
||
const currentProgressRingMotion = {
|
||
initial: {
|
||
scale: 1,
|
||
opacity: 0.34
|
||
},
|
||
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',
|
||
times: [0, 0.5, 1]
|
||
}
|
||
}
|
||
}
|
||
|
||
const expenseTotal = computed(() => {
|
||
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)
|
||
return formatCurrency(total)
|
||
})
|
||
const submitConfirmAmountDisplay = computed(() =>
|
||
isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value
|
||
)
|
||
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
|
||
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
|
||
|
||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||
const expenseTableColumnCount = computed(
|
||
() => 7 + (isEditableRequest.value ? 1 : 0)
|
||
)
|
||
const canEditDetailNote = computed(() => isDraftRequest.value)
|
||
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')
|
||
}
|
||
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
|
||
const detailNote = computed(() => {
|
||
if (detailNoteSource.value) {
|
||
return stripDetailNoteRiskTags(detailNoteSource.value)
|
||
}
|
||
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
||
})
|
||
const detailNoteEditorView = computed({
|
||
get: () => stripDetailNoteRiskTags(detailNoteEditor.value),
|
||
set: (value) => {
|
||
detailNoteEditor.value = mergeDetailNoteVisibleTextWithTags(value, detailNoteEditor.value)
|
||
}
|
||
})
|
||
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
||
const detailNoteTags = computed(() =>
|
||
extractRiskTagsFromText(canEditDetailNote.value ? detailNoteEditor.value : detailNoteSource.value)
|
||
)
|
||
watch(
|
||
() => [request.value.claimId, detailNoteSource.value],
|
||
([, nextNote]) => {
|
||
detailNoteEditor.value = nextNote
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
watch(
|
||
() => request.value.claimId,
|
||
() => {
|
||
riskFlagPreviewSnapshot.value = null
|
||
appliedSmartEntryRecognitionPayloadIds.clear()
|
||
bindSmartEntryRecognitionTask()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
const draftBlockingIssues = computed(() =>
|
||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||
)
|
||
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
|
||
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(() =>
|
||
smartEntryUploadDialogOpen.value && Boolean(uploadingExpenseId.value)
|
||
)
|
||
const attachmentPreviewEntries = computed(() =>
|
||
expenseItems.value
|
||
.filter((item) => canPreviewAttachment(item))
|
||
.map((item, index) => ({
|
||
item,
|
||
itemId: item.id,
|
||
index,
|
||
name: resolveAttachmentDisplayName(item) || `第 ${index + 1} 条附件`,
|
||
metadata: resolveAttachmentMeta(item)
|
||
}))
|
||
)
|
||
const currentAttachmentPreviewIndex = computed(() =>
|
||
attachmentPreviewEntries.value.findIndex((entry) => entry.itemId === attachmentPreviewItemId.value)
|
||
)
|
||
const currentAttachmentPreviewEntry = computed(() => {
|
||
const index = currentAttachmentPreviewIndex.value
|
||
return index >= 0 ? attachmentPreviewEntries.value[index] : null
|
||
})
|
||
const attachmentPreviewIndexLabel = computed(() => {
|
||
const currentIndex = currentAttachmentPreviewIndex.value
|
||
const total = attachmentPreviewEntries.value.length
|
||
return currentIndex >= 0 && total > 0 ? `${currentIndex + 1} / ${total}` : ''
|
||
})
|
||
const canNavigateAttachmentPreview = computed(() => attachmentPreviewEntries.value.length > 1)
|
||
const currentAttachmentPreviewInsight = computed(() => {
|
||
const entry = currentAttachmentPreviewEntry.value
|
||
if (!entry) {
|
||
return null
|
||
}
|
||
|
||
return buildAttachmentInsightViewModel(resolveAttachmentMeta(entry.item), entry.item)
|
||
})
|
||
const currentAttachmentPreviewRiskCards = computed(() => {
|
||
const entry = currentAttachmentPreviewEntry.value
|
||
if (!entry) {
|
||
return []
|
||
}
|
||
|
||
return buildAttachmentRiskCards({
|
||
expenseItems: [entry.item],
|
||
attachmentMetaByItemId: expenseAttachmentMeta
|
||
})
|
||
})
|
||
|
||
function applyLocalExpenseItemPatch(itemId, patch) {
|
||
expenseItems.value = rebuildExpenseItems(
|
||
expenseItems.value.map((item) => (item.id === itemId ? { ...item, ...patch } : item)),
|
||
request.value
|
||
)
|
||
}
|
||
|
||
function resolveAttachmentMeta(item) {
|
||
return expenseAttachmentMeta[item.id] || null
|
||
}
|
||
|
||
function resolveClaimRiskFlags() {
|
||
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
|
||
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
|
||
}
|
||
|
||
function resolveCurrentStandardAdjustmentMap() {
|
||
return buildCurrentStandardAdjustmentMap(request.value, resolveClaimRiskFlags())
|
||
}
|
||
|
||
function resolveExpenseItemForRiskCard(card) {
|
||
return resolveExpenseItemForRiskCardModel(card, expenseItems.value)
|
||
}
|
||
|
||
function resolveExpenseItemsForRiskCard(card) {
|
||
return resolveExpenseItemsForRiskCardModel(card, expenseItems.value)
|
||
}
|
||
|
||
function filterSubmitterResolvedRiskCards(cards, businessStage) {
|
||
const viewerContext = riskViewerContext.value || {}
|
||
return filterSubmitterResolvedRiskCardsModel({
|
||
cards,
|
||
businessStage,
|
||
isCurrentApplicant: isCurrentApplicant.value,
|
||
isPrivilegedRiskViewer: Boolean(
|
||
viewerContext.isAdminViewer
|
||
|| viewerContext.isBudgetReviewer
|
||
|| viewerContext.isDirectManagerReviewer
|
||
|| viewerContext.isFinanceReviewer
|
||
|| viewerContext.canViewApprovalRiskAdvice
|
||
),
|
||
expenseItems: expenseItems.value,
|
||
standardAdjustmentMap: resolveCurrentStandardAdjustmentMap()
|
||
})
|
||
}
|
||
|
||
function isRiskCardMissingExpenseNote(card) {
|
||
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
|
||
}
|
||
|
||
function resolveRiskWarningNotes(card) {
|
||
const notes = resolveExpenseItemsForRiskCard(card)
|
||
.map((item) => String(item?.itemNote || '').trim())
|
||
.filter(Boolean)
|
||
return [...new Set(notes)]
|
||
}
|
||
|
||
async function buildStandardAdjustmentPayload() {
|
||
return buildStandardAdjustmentPayloadModel({
|
||
warnings: submitRiskCards.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
|
||
})
|
||
}
|
||
|
||
function resolveAttachmentDisplayName(item) {
|
||
const metadata = resolveAttachmentMeta(item)
|
||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||
}
|
||
|
||
function hasStoredAttachmentReference(item) {
|
||
return String(item?.invoiceId || '').includes('/')
|
||
}
|
||
|
||
function resolveAttachmentPreviewTitle(item) {
|
||
const fileName = resolveAttachmentDisplayName(item)
|
||
return fileName ? `预览附件:${fileName}` : '预览附件'
|
||
}
|
||
|
||
function resolveAttachmentRecognition(item) {
|
||
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
|
||
}
|
||
|
||
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}`
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
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) {
|
||
if (!item?.invoiceId) {
|
||
return false
|
||
}
|
||
const metadata = resolveAttachmentMeta(item)
|
||
if (metadata) {
|
||
return metadata.previewable !== false
|
||
}
|
||
return true
|
||
}
|
||
|
||
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 = ''
|
||
attachmentPreviewItemId.value = ''
|
||
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)
|
||
}
|
||
|
||
function resolveExpenseIssues(item) {
|
||
return buildExpenseDraftIssues(item)
|
||
}
|
||
|
||
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 || '已上传',
|
||
tone: normalizeRiskTone(analysis.severity || 'low'),
|
||
headline: analysis.headline || 'AI提示',
|
||
summary: analysis.summary || '',
|
||
points: Array.isArray(analysis.points) ? analysis.points : [],
|
||
suggestion: analysis.suggestion || ''
|
||
}
|
||
}
|
||
|
||
const claimRiskState = buildItemClaimRiskState(item, resolveClaimRiskFlags())
|
||
if (claimRiskState) {
|
||
return claimRiskState
|
||
}
|
||
|
||
if (!item.invoiceId) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
label: '已上传',
|
||
tone: 'low',
|
||
headline: 'AI提示:附件已上传',
|
||
summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。',
|
||
points: [],
|
||
suggestion: ''
|
||
}
|
||
}
|
||
|
||
function showExpenseRisk(item) {
|
||
return Boolean(resolveExpenseRiskState(item))
|
||
}
|
||
|
||
function isMajorExpenseRisk(item) {
|
||
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
|
||
}
|
||
|
||
function hasExpenseRiskOrAbnormal(item) {
|
||
const state = resolveExpenseRiskState(item)
|
||
return Boolean(
|
||
String(item?.itemNote || '').trim()
|
||
|| normalizeRiskTone(state?.tone) !== 'low'
|
||
|| item?.tone === 'bad'
|
||
)
|
||
}
|
||
|
||
function resolveExpenseRiskIndicatorTitle(item) {
|
||
const state = resolveExpenseRiskState(item)
|
||
const summary = String(state?.summary || state?.headline || '').trim()
|
||
return summary ? `查看风险提示:${summary}` : '查看风险提示'
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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 }
|
||
)
|
||
|
||
const aiAdvice = computed(() => {
|
||
const completionItems = isEditableRequest.value
|
||
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||
: []
|
||
const currentBusinessStage = isApplicationDocument.value ? 'expense_application' : 'reimbursement'
|
||
const directRiskCards = filterRiskCardsByBusinessStage(
|
||
buildAttachmentRiskCards({
|
||
expenseItems: expenseItems.value,
|
||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||
claimRiskFlags: resolveClaimRiskFlags(),
|
||
businessStage: currentBusinessStage
|
||
}),
|
||
currentBusinessStage
|
||
)
|
||
const hasActionableRiskCards = directRiskCards.some(
|
||
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
|
||
)
|
||
const summaryRiskCards = filterRiskCardsByBusinessStage(
|
||
buildClaimSummaryRiskCards({
|
||
...(request.value || {}),
|
||
businessStage: currentBusinessStage
|
||
}),
|
||
currentBusinessStage
|
||
)
|
||
const materialPrompts = currentBusinessStage === 'reimbursement'
|
||
? buildTravelReceiptMaterialPrompts(request.value, expenseItems.value)
|
||
: []
|
||
const profileAdviceItems = currentBusinessStage === 'reimbursement'
|
||
? buildEmployeeProfileAdviceItems(employeeRiskProfile.value)
|
||
: []
|
||
const scopedRiskCards = [
|
||
...(hasActionableRiskCards ? [] : summaryRiskCards),
|
||
...filterSubmitterResolvedRiskCards(directRiskCards, currentBusinessStage)
|
||
]
|
||
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
|
||
|
||
return buildAiAdviceViewModel({
|
||
completionItems,
|
||
materialPrompts,
|
||
profileAdviceItems,
|
||
riskCards
|
||
})
|
||
})
|
||
|
||
const hasVisibleRiskCards = computed(() =>
|
||
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||
)
|
||
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
|
||
const showCompactSafeAdvice = computed(() =>
|
||
isEditableRequest.value
|
||
&& !isApplicationDocument.value
|
||
&& !draftBlockingIssues.value.length
|
||
)
|
||
const showAiAdvicePanel = computed(() => (
|
||
(
|
||
isEditableRequest.value
|
||
&& (
|
||
hasAdviceSections.value
|
||
|| showCompactSafeAdvice.value
|
||
)
|
||
)
|
||
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|
||
|| (!isEditableRequest.value && isCurrentApplicant.value && hasVisibleRiskCards.value)
|
||
))
|
||
|
||
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) => {
|
||
const cardItemIds = [
|
||
card?.itemId,
|
||
card?.item_id,
|
||
...(Array.isArray(card?.itemIds) ? card.itemIds : []),
|
||
...(Array.isArray(card?.item_ids) ? card.item_ids : [])
|
||
].map((value) => String(value || '').trim()).filter(Boolean)
|
||
return cardItemIds.includes(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: 'nearest', inline: 'nearest' })
|
||
|
||
if (highlightedRiskCardTimer) {
|
||
window.clearTimeout(highlightedRiskCardTimer)
|
||
}
|
||
highlightedRiskCardTimer = window.setTimeout(() => {
|
||
highlightedRiskCardId.value = ''
|
||
highlightedRiskCardTimer = 0
|
||
}, 1800)
|
||
}
|
||
|
||
const aiAdviceTitle = computed(() => {
|
||
if (!isEditableRequest.value && isCurrentApplicant.value) {
|
||
return isApplicationDocument.value ? '申请风险提示' : '风险提示'
|
||
}
|
||
if (isEditableRequest.value && isApplicationDocument.value) {
|
||
return '表单自查提示'
|
||
}
|
||
return isEditableRequest.value ? 'AI建议' : '风险提示'
|
||
})
|
||
const aiAdviceHint = computed(() => {
|
||
if (!isEditableRequest.value && isCurrentApplicant.value) {
|
||
return isApplicationDocument.value
|
||
? '展示申请单已识别的风险点及原因,请逐条确认或补充说明后再提交给领导审批。'
|
||
: '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
|
||
}
|
||
return isEditableRequest.value
|
||
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。')
|
||
: '展示系统已识别的风险点,便于审批和后续整改。'
|
||
})
|
||
|
||
const submitActionLabel = computed(() => {
|
||
return resolveSubmitActionLabel({
|
||
isApplicationDocument: isApplicationDocument.value,
|
||
submitBusy: submitBusy.value
|
||
})
|
||
})
|
||
const submitActionIcon = computed(() => resolveSubmitActionIcon({
|
||
isApplicationDocument: isApplicationDocument.value
|
||
}))
|
||
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
|
||
isApplicationDocument: isApplicationDocument.value,
|
||
hasHighRiskWarnings: aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||
}))
|
||
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
|
||
|
||
const submitRiskCards = computed(() =>
|
||
aiAdvice.value.riskCards
|
||
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||
.map((card, index) => ({
|
||
...card,
|
||
id: String(card.id || `submit-risk-${index}`),
|
||
tags: resolveRiskTags(card)
|
||
}))
|
||
)
|
||
const submitConfirmSecondaryText = computed(() => (
|
||
!isApplicationDocument.value && submitRiskCards.value.length
|
||
? '按职级标准报销'
|
||
: ''
|
||
))
|
||
const submitRiskWarnings = computed(() =>
|
||
submitRiskCards.value.filter((card) => isRiskCardMissingExpenseNote(card))
|
||
)
|
||
const submitExplainedRiskWarnings = computed(() =>
|
||
submitRiskCards.value.filter((card) => !isRiskCardMissingExpenseNote(card))
|
||
)
|
||
const hasMissingSubmitRiskWarnings = computed(() => submitRiskWarnings.value.length > 0)
|
||
const submitRiskReviewWarnings = computed(() =>
|
||
hasMissingSubmitRiskWarnings.value ? submitRiskWarnings.value : submitExplainedRiskWarnings.value
|
||
)
|
||
const currentSubmitRiskWarning = computed(() => submitRiskReviewWarnings.value[riskOverrideIndex.value] || null)
|
||
const currentSubmitRiskWarningNotes = computed(() =>
|
||
currentSubmitRiskWarning.value ? resolveRiskWarningNotes(currentSubmitRiskWarning.value) : []
|
||
)
|
||
const riskOverrideIndexLabel = computed(() =>
|
||
submitRiskReviewWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskReviewWarnings.value.length}` : ''
|
||
)
|
||
const riskOverrideBadgeTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'warning')
|
||
const riskOverrideDialogTitle = computed(() => (
|
||
hasMissingSubmitRiskWarnings.value
|
||
? `当前存在 ${submitRiskWarnings.value.length} 条需说明的风险`
|
||
: `请确认 ${submitExplainedRiskWarnings.value.length} 条风险及异常说明`
|
||
))
|
||
const riskOverrideDialogDescription = computed(() => (
|
||
hasMissingSubmitRiskWarnings.value
|
||
? '请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,可选择按职级最高可报销金额重新计算。'
|
||
: '请核对风险点与已填写的异常说明,确认后进入提交确认。'
|
||
))
|
||
const riskOverrideCancelText = computed(() => (
|
||
hasMissingSubmitRiskWarnings.value ? '返回整改' : '返回核对'
|
||
))
|
||
const riskOverrideConfirmText = computed(() =>
|
||
hasMissingSubmitRiskWarnings.value ? '按职级标准重算' : '确认说明'
|
||
)
|
||
const riskOverrideConfirmTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'primary')
|
||
const riskOverrideConfirmIcon = computed(() =>
|
||
hasMissingSubmitRiskWarnings.value ? 'mdi mdi-calculator-variant-outline' : 'mdi mdi-check-circle-outline'
|
||
)
|
||
const riskOverrideGuidanceTitle = computed(() => (
|
||
hasMissingSubmitRiskWarnings.value
|
||
? '请在费用明细的“异常说明”列补充原因后再提交。'
|
||
: '已填写异常说明,请确认说明会随单据进入审批。'
|
||
))
|
||
const riskOverrideGuidanceText = computed(() => (
|
||
hasMissingSubmitRiskWarnings.value
|
||
? '如果不补充说明,可直接选择按职级标准重算,超出标准的部分由员工自担。'
|
||
: '确认后系统会继续进入提交确认,领导和财务可看到这些风险及对应说明。'
|
||
))
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
function openRiskOverrideDialog() {
|
||
const warnings = submitRiskReviewWarnings.value
|
||
if (!warnings.length) {
|
||
return
|
||
}
|
||
riskOverrideIndex.value = 0
|
||
riskOverrideDialogOpen.value = true
|
||
}
|
||
|
||
function closeRiskOverrideDialog() {
|
||
if (riskOverrideBusy.value) {
|
||
return
|
||
}
|
||
riskOverrideDialogOpen.value = false
|
||
}
|
||
|
||
function goToPreviousSubmitRisk() {
|
||
if (!submitRiskReviewWarnings.value.length) {
|
||
return
|
||
}
|
||
riskOverrideIndex.value =
|
||
(riskOverrideIndex.value - 1 + submitRiskReviewWarnings.value.length) % submitRiskReviewWarnings.value.length
|
||
}
|
||
|
||
function goToNextSubmitRisk() {
|
||
if (!submitRiskReviewWarnings.value.length) {
|
||
return
|
||
}
|
||
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskReviewWarnings.value.length
|
||
}
|
||
|
||
function confirmRiskExplanation() {
|
||
if (riskOverrideBusy.value || submitBusy.value) {
|
||
return
|
||
}
|
||
riskOverrideDialogOpen.value = false
|
||
submitConfirmDialogOpen.value = true
|
||
}
|
||
|
||
function confirmRiskOverrideDialog() {
|
||
if (hasMissingSubmitRiskWarnings.value) {
|
||
confirmStandardAdjustment()
|
||
return
|
||
}
|
||
confirmRiskExplanation()
|
||
}
|
||
|
||
function confirmStandardAdjustment() {
|
||
if (riskOverrideBusy.value || standardAdjustmentBusy.value) {
|
||
return
|
||
}
|
||
const claimId = String(request.value?.claimId || '').trim()
|
||
if (!claimId) {
|
||
toast('\u5f53\u524d\u8349\u7a3f\u7f3a\u5c11 claimId\uff0c\u6682\u65f6\u65e0\u6cd5\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u7b97\u3002')
|
||
return
|
||
}
|
||
|
||
riskOverrideDialogOpen.value = false
|
||
submitConfirmDialogOpen.value = false
|
||
standardAdjustmentBusy.value = true
|
||
const taskSeq = ++standardAdjustmentTaskSeq
|
||
toast('\u6b63\u5728\u540e\u53f0\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u65b0\u6d4b\u7b97\u8d39\u7528\u3002')
|
||
void runStandardAdjustmentRecalculation(claimId, taskSeq)
|
||
}
|
||
|
||
async function runStandardAdjustmentRecalculation(claimId, taskSeq) {
|
||
try {
|
||
const payload = await buildStandardAdjustmentPayload()
|
||
if (!payload.risks.length) {
|
||
toast('\u5f53\u524d\u98ce\u9669\u6682\u672a\u5339\u914d\u5230\u53ef\u91cd\u7b97\u7684\u8d39\u7528\u660e\u7ec6\uff0c\u8bf7\u5148\u8865\u5145\u5f02\u5e38\u8bf4\u660e\u3002')
|
||
return
|
||
}
|
||
const response = await acceptExpenseClaimStandardAdjustment(claimId, payload)
|
||
if (taskSeq !== standardAdjustmentTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
|
||
return
|
||
}
|
||
applyStandardAdjustmentResponse(response)
|
||
toast('\u5df2\u6309\u804c\u7ea7\u6700\u9ad8\u62a5\u9500\u6807\u51c6\u91cd\u7b97\u5b9e\u9645\u62a5\u9500\u91d1\u989d\uff0c\u53ef\u7ee7\u7eed\u63d0\u4ea4\u5ba1\u6279\u3002')
|
||
emit('request-updated', { claimId: request.value.claimId })
|
||
} catch (error) {
|
||
toast(error?.message || '\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u7b97\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002')
|
||
} finally {
|
||
if (taskSeq === standardAdjustmentTaskSeq) {
|
||
standardAdjustmentBusy.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
function populateExpenseEditor(item) {
|
||
editingExpenseId.value = item.id
|
||
expenseEditor.itemDate = item.itemDate || ''
|
||
expenseEditor.itemType = item.itemType || 'other'
|
||
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
|
||
expenseEditor.itemLocation =
|
||
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
|
||
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
||
expenseEditor.itemNote = item.itemNote || ''
|
||
expenseEditor.invoiceId = item.invoiceId || ''
|
||
}
|
||
|
||
function startExpenseEdit(item) {
|
||
if (!isEditableRequest.value || actionBusy.value) {
|
||
return
|
||
}
|
||
if (item?.isSystemGenerated) {
|
||
toast('系统自动计算的补贴行不能手动编辑。')
|
||
return
|
||
}
|
||
|
||
populateExpenseEditor(item)
|
||
}
|
||
|
||
function validateExpenseEditor() {
|
||
if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
|
||
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
|
||
}
|
||
if (isPlaceholderValue(expenseEditor.itemType)) {
|
||
return '请选择费用项目。'
|
||
}
|
||
if (
|
||
!isPlaceholderValue(expenseEditor.itemReason)
|
||
&&
|
||
isRouteDescriptionExpenseType(expenseEditor.itemType)
|
||
&& !isValidRouteDescription(expenseEditor.itemReason)
|
||
) {
|
||
return '行程说明格式应为“起始地-目的地”,例如:广州南-北京南。'
|
||
}
|
||
|
||
const amountText = String(expenseEditor.itemAmount || '').trim()
|
||
if (amountText) {
|
||
const amount = Number(amountText)
|
||
if (!Number.isFinite(amount) || amount < 0) {
|
||
return '请输入不小于 0 的费用金额。'
|
||
}
|
||
}
|
||
return ''
|
||
}
|
||
|
||
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 ? '当前单据已有附件识别任务,请等待识别完成。' : '附件已转入后台识别,费用明细将在识别完成后自动更新。')
|
||
}
|
||
|
||
function triggerExpenseUpload(item) {
|
||
if (!isEditableRequest.value || actionBusy.value) {
|
||
return
|
||
}
|
||
|
||
if (!request.value.claimId) {
|
||
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
||
return
|
||
}
|
||
|
||
if (item?.isSystemGenerated) {
|
||
toast('系统自动计算的补贴行无需上传附件。')
|
||
return
|
||
}
|
||
|
||
if (item?.invoiceId) {
|
||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||
return
|
||
}
|
||
|
||
pendingUploadExpenseId.value = item.id
|
||
if (expenseUploadInput.value) {
|
||
expenseUploadInput.value.value = ''
|
||
expenseUploadInput.value.click()
|
||
}
|
||
}
|
||
|
||
async function loadAttachmentPreview(item) {
|
||
if (!request.value.claimId || !item?.invoiceId) {
|
||
return
|
||
}
|
||
|
||
attachmentPreviewLoading.value = true
|
||
attachmentPreviewError.value = ''
|
||
attachmentPreviewItemId.value = item.id
|
||
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
||
let metadata = resolveAttachmentMeta(item)
|
||
|
||
try {
|
||
if (!metadata) {
|
||
try {
|
||
metadata = await refreshExpenseAttachmentMeta(item.id)
|
||
} catch (error) {
|
||
if (!hasStoredAttachmentReference(item)) {
|
||
throw new Error('当前附件只有文件名记录,原件尚未保存到单据中,请重新上传后预览。')
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
if (metadata?.previewable === false) {
|
||
throw new Error('当前附件暂不支持直接预览。')
|
||
}
|
||
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
||
attachmentPreviewMediaType.value =
|
||
String(metadata?.preview_kind || '').trim() === 'image'
|
||
? 'image/png'
|
||
: String(metadata?.media_type || '').trim()
|
||
const blob = await fetchExpenseClaimItemAttachmentPreview(request.value.claimId, item.id)
|
||
revokeAttachmentPreviewUrl()
|
||
attachmentPreviewUrl.value = URL.createObjectURL(blob)
|
||
attachmentPreviewMediaType.value = blob.type || attachmentPreviewMediaType.value
|
||
} catch (error) {
|
||
attachmentPreviewError.value = error?.message || '附件预览失败,请稍后重试。'
|
||
} finally {
|
||
attachmentPreviewLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function openAttachmentPreview(item) {
|
||
if (!request.value.claimId || !canPreviewAttachment(item)) {
|
||
return
|
||
}
|
||
|
||
closeAttachmentPreview()
|
||
attachmentPreviewOpen.value = true
|
||
await loadAttachmentPreview(item)
|
||
}
|
||
|
||
async function goToAttachmentPreview(offset) {
|
||
if (!canNavigateAttachmentPreview.value || attachmentPreviewLoading.value) {
|
||
return
|
||
}
|
||
|
||
const entries = attachmentPreviewEntries.value
|
||
const currentIndex = currentAttachmentPreviewIndex.value
|
||
const nextIndex = (currentIndex + offset + entries.length) % entries.length
|
||
const nextEntry = entries[nextIndex]
|
||
if (nextEntry?.item) {
|
||
await loadAttachmentPreview(nextEntry.item)
|
||
}
|
||
}
|
||
|
||
function goToPreviousAttachmentPreview() {
|
||
void goToAttachmentPreview(-1)
|
||
}
|
||
|
||
function goToNextAttachmentPreview() {
|
||
void goToAttachmentPreview(1)
|
||
}
|
||
|
||
async function uploadExpenseFile(item, file) {
|
||
if (!item || !file) {
|
||
return
|
||
}
|
||
if (item?.isSystemGenerated) {
|
||
toast('系统自动计算的补贴行无需上传附件。')
|
||
return
|
||
}
|
||
|
||
if (item?.invoiceId) {
|
||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||
return
|
||
}
|
||
|
||
uploadingExpenseId.value = item.id
|
||
|
||
try {
|
||
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
||
applyClaimRiskFlagsPayload(payload)
|
||
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||
const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name)
|
||
applyLocalExpenseItemPatch(item.id, {
|
||
...itemPatch
|
||
})
|
||
populateExpenseEditor({ ...item, ...itemPatch })
|
||
|
||
emit('request-updated', { claimId: request.value.claimId })
|
||
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
|
||
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
|
||
return true
|
||
} catch (error) {
|
||
toast(error?.message || '附件上传失败,请稍后重试。')
|
||
return false
|
||
} 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)
|
||
applyClaimRiskFlagsPayload(payload)
|
||
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
|
||
const fileList = target?.files
|
||
const fileCount = fileList?.length || 0
|
||
const file = fileList?.[0]
|
||
const itemId = pendingUploadExpenseId.value
|
||
pendingUploadExpenseId.value = ''
|
||
|
||
if (target) {
|
||
target.value = ''
|
||
}
|
||
|
||
if (fileCount > 1) {
|
||
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
if (item?.isSystemGenerated) {
|
||
toast('系统自动计算的补贴行不能删除。')
|
||
return
|
||
}
|
||
|
||
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 = ''
|
||
expenseEditor.itemNote = ''
|
||
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 = ''
|
||
}
|
||
}
|
||
|
||
async function saveExpenseEdit(item) {
|
||
if (actionBusy.value) {
|
||
toast(uploadingExpenseId.value ? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。')
|
||
return
|
||
}
|
||
if (!request.value.claimId) {
|
||
toast('当前草稿缺少 claimId,暂时无法保存费用明细。')
|
||
return
|
||
}
|
||
|
||
const validationError = validateExpenseEditor()
|
||
if (validationError) {
|
||
toast(validationError)
|
||
return
|
||
}
|
||
|
||
savingExpenseId.value = item.id
|
||
try {
|
||
const nextInvoiceId = expenseEditor.invoiceId.trim()
|
||
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
|
||
const amountText = String(expenseEditor.itemAmount || '').trim()
|
||
const nextAmount = amountText ? Number(amountText) : 0
|
||
const itemPayload = {
|
||
item_type: expenseEditor.itemType,
|
||
item_reason: expenseEditor.itemReason.trim(),
|
||
item_location: preservedLocation,
|
||
item_note: expenseEditor.itemNote.trim(),
|
||
item_amount: nextAmount,
|
||
invoice_id: nextInvoiceId
|
||
}
|
||
if (expenseEditor.itemDate) {
|
||
itemPayload.item_date = expenseEditor.itemDate
|
||
}
|
||
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
|
||
applyLocalExpenseItemPatch(item.id, {
|
||
itemDate: expenseEditor.itemDate || item.itemDate,
|
||
itemType: expenseEditor.itemType,
|
||
itemReason: expenseEditor.itemReason.trim(),
|
||
itemLocation: preservedLocation,
|
||
itemNote: expenseEditor.itemNote.trim(),
|
||
itemAmount: nextAmount,
|
||
invoiceId: nextInvoiceId
|
||
})
|
||
let riskNotice = ''
|
||
if (nextInvoiceId) {
|
||
try {
|
||
const attachment = await refreshExpenseAttachmentMeta(item.id)
|
||
riskNotice = buildAttachmentRiskNotice(attachment)
|
||
} catch {
|
||
delete expenseAttachmentMeta[item.id]
|
||
}
|
||
} else {
|
||
delete expenseAttachmentMeta[item.id]
|
||
}
|
||
editingExpenseId.value = ''
|
||
toast(riskNotice || '费用明细已保存。')
|
||
emit('request-updated', { claimId: request.value.claimId })
|
||
} catch (error) {
|
||
toast(error?.message || '费用明细保存失败,请稍后重试。')
|
||
} finally {
|
||
savingExpenseId.value = ''
|
||
}
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
if (!request.value.claimId) {
|
||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||
return
|
||
}
|
||
|
||
if (!canSubmit.value) {
|
||
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
||
return
|
||
}
|
||
|
||
if (standardAdjustmentBusy.value) {
|
||
toast('费用正在按职级标准重新测算,完成后再提交审批。')
|
||
return
|
||
}
|
||
|
||
if (draftBlockingIssues.value.length) {
|
||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||
return
|
||
}
|
||
|
||
if (submitRiskReviewWarnings.value.length) {
|
||
openRiskOverrideDialog()
|
||
return
|
||
}
|
||
|
||
submitConfirmDialogOpen.value = true
|
||
}
|
||
|
||
function closeSubmitConfirmDialog() {
|
||
if (submitBusy.value) {
|
||
return
|
||
}
|
||
|
||
submitConfirmDialogOpen.value = false
|
||
}
|
||
|
||
function confirmSubmitRequest() {
|
||
if (!request.value.claimId) {
|
||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||
submitConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
if (!canSubmit.value) {
|
||
toast('当前单据正在保存或处理附件,请稍后再提交审批。')
|
||
submitConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
if (standardAdjustmentBusy.value) {
|
||
toast('费用正在按职级标准重新测算,完成后再提交审批。')
|
||
submitConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
if (draftBlockingIssues.value.length) {
|
||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||
submitConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
const claimId = String(request.value.claimId || '').trim()
|
||
const documentNo = request.value.id
|
||
const isApplication = isApplicationDocument.value
|
||
submitBusy.value = true
|
||
submitConfirmDialogOpen.value = false
|
||
const taskSeq = ++submitTaskSeq
|
||
toast('\u6b63\u5728\u540e\u53f0\u63d0\u4ea4\u5ba1\u6279\uff0c\u5b8c\u6210\u540e\u4f1a\u81ea\u52a8\u66f4\u65b0\u5355\u636e\u72b6\u6001\u3002')
|
||
void runSubmitRequest(claimId, documentNo, isApplication, taskSeq)
|
||
}
|
||
|
||
async function runSubmitRequest(claimId, documentNo, isApplication, taskSeq) {
|
||
try {
|
||
const payload = await submitExpenseClaim(claimId)
|
||
if (taskSeq !== submitTaskSeq || String(request.value?.claimId || '').trim() !== claimId) {
|
||
return
|
||
}
|
||
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
||
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
||
if (claimStatus === 'submitted') {
|
||
toast(
|
||
isApplication
|
||
? `${documentNo} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
||
: `${documentNo} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}`
|
||
)
|
||
} else if (claimStatus === 'supplement') {
|
||
toast(`${documentNo} 自动检测未通过,已转待补充。`)
|
||
} else {
|
||
toast(`${documentNo} 提交结果已更新。`)
|
||
}
|
||
submitConfirmDialogOpen.value = false
|
||
emit('request-updated', { claimId })
|
||
} catch (error) {
|
||
if (taskSeq === submitTaskSeq) {
|
||
toast(error?.message || '提交审批失败,请稍后重试。')
|
||
}
|
||
} finally {
|
||
if (taskSeq === submitTaskSeq) {
|
||
submitBusy.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleDeleteRequest() {
|
||
if (!request.value.claimId) {
|
||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||
return
|
||
}
|
||
|
||
if (!canDeleteRequest.value) {
|
||
toast(
|
||
isArchivedRequest.value
|
||
? '已归档单据不能删除,只有高级管理员可以执行删除。'
|
||
: isApplicationDocument.value
|
||
? '当前申请单已进入审批流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
|
||
: '当前单据已进入流程,只有草稿、待补充或退回待提交阶段的申请人本人或系统管理员可以删除。'
|
||
)
|
||
return
|
||
}
|
||
|
||
deleteDialogOpen.value = true
|
||
}
|
||
|
||
function closeDeleteDialog() {
|
||
if (deleteBusy.value) {
|
||
return
|
||
}
|
||
|
||
deleteDialogOpen.value = false
|
||
}
|
||
|
||
async function confirmDeleteRequest() {
|
||
if (!request.value.claimId) {
|
||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||
return
|
||
}
|
||
|
||
deleteBusy.value = true
|
||
try {
|
||
const payload = await deleteExpenseClaim(request.value.claimId)
|
||
deleteDialogOpen.value = false
|
||
toast(payload?.message || `${request.value.id} ${isApplicationDocument.value ? '申请单' : '报销单'}已删除。`)
|
||
emit('request-deleted', {
|
||
claimId: request.value.claimId,
|
||
claimNo: request.value.claimNo || request.value.documentNo || request.value.id,
|
||
documentNo: request.value.documentNo || request.value.id
|
||
})
|
||
} catch (error) {
|
||
toast(error?.message || '删除单据失败,请稍后重试。')
|
||
} finally {
|
||
deleteBusy.value = false
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
async function confirmReturnRequest(payload) {
|
||
if (!request.value.claimId) {
|
||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||
return
|
||
}
|
||
|
||
returnBusy.value = true
|
||
try {
|
||
await returnExpenseClaim(request.value.claimId, payload)
|
||
returnDialogOpen.value = false
|
||
toast(`${request.value.id} 已退回待提交。`)
|
||
emit('request-updated', { claimId: request.value.claimId })
|
||
} catch (error) {
|
||
toast(error?.message || '退回单据失败,请稍后重试。')
|
||
} finally {
|
||
returnBusy.value = false
|
||
}
|
||
}
|
||
|
||
function handleApproveRequest() {
|
||
if (!request.value.claimId) {
|
||
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
||
return
|
||
}
|
||
|
||
if (!canApproveRequest.value) {
|
||
toast('当前节点暂不支持审批通过。')
|
||
return
|
||
}
|
||
|
||
approvalRiskConfirmed.value = !approvalRiskConfirmRequired.value
|
||
approveConfirmDialogOpen.value = true
|
||
}
|
||
|
||
function closeApproveConfirmDialog() {
|
||
if (approveBusy.value) {
|
||
return
|
||
}
|
||
|
||
approveConfirmDialogOpen.value = false
|
||
}
|
||
|
||
function resolveApproveErrorMessage(error) {
|
||
const message = String(error?.message || '').trim()
|
||
if (message.includes('未找到同部门 P8 预算审批人')) {
|
||
return '当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。'
|
||
}
|
||
return message || '审批通过失败,请稍后重试。'
|
||
}
|
||
|
||
async function confirmApproveRequest() {
|
||
if (!request.value.claimId) {
|
||
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
||
approveConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
if (!canApproveRequest.value) {
|
||
toast('当前节点暂不支持审批通过。')
|
||
approveConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
if (approvalRiskConfirmRequired.value && !approvalRiskConfirmed.value) {
|
||
toast('请先确认已核对风险说明和佐证材料,再继续审批。')
|
||
return
|
||
}
|
||
|
||
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
|
||
toast('预算已超过警戒值,请填写预算审批意见后再通过。')
|
||
return
|
||
}
|
||
|
||
approveBusy.value = true
|
||
try {
|
||
const responsePayload = await approveExpenseClaim(request.value.claimId, {
|
||
opinion: leaderOpinion.value.trim() || '同意'
|
||
})
|
||
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
|
||
approveConfirmDialogOpen.value = false
|
||
approvalRiskConfirmed.value = false
|
||
leaderOpinion.value = ''
|
||
toast(
|
||
isApplicationDocument.value && generatedDraftClaimNo
|
||
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
|
||
: approvalSuccessToast.value
|
||
)
|
||
emit('request-updated', {
|
||
claimId: request.value.claimId,
|
||
claim: responsePayload
|
||
})
|
||
emit('backToRequests')
|
||
} catch (error) {
|
||
toast(resolveApproveErrorMessage(error))
|
||
} finally {
|
||
approveBusy.value = false
|
||
}
|
||
}
|
||
|
||
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
|
||
})
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
standardAdjustmentTaskSeq += 1
|
||
standardAdjustmentBusy.value = false
|
||
submitTaskSeq += 1
|
||
submitBusy.value = false
|
||
if (highlightedRiskCardTimer) {
|
||
window.clearTimeout(highlightedRiskCardTimer)
|
||
highlightedRiskCardTimer = 0
|
||
}
|
||
if (stopSmartEntryRecognitionTask) {
|
||
stopSmartEntryRecognitionTask()
|
||
stopSmartEntryRecognitionTask = null
|
||
}
|
||
closeAttachmentPreview()
|
||
})
|
||
|
||
return {
|
||
emit, actionBusy, aiAdvice, aiAdviceHint, aiAdviceTitle, attachmentPreviewError, attachmentPreviewIndexLabel,
|
||
attachmentPreviewLoading, attachmentPreviewMediaType, attachmentPreviewName, attachmentPreviewOpen,
|
||
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
|
||
approvalConfirmDescription, approvalOpinionHint,
|
||
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
|
||
approvalRiskConfirmed, approvalRiskConfirmItems, approvalRiskConfirmRequired,
|
||
applicationDetailFactItems, relatedApplicationFactItems,
|
||
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
|
||
canNavigateAttachmentPreview,
|
||
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
||
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
|
||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||
confirmPayRequest, confirmRiskExplanation, confirmRiskOverrideDialog, confirmStandardAdjustment, confirmSmartEntryUpload,
|
||
chooseSmartEntryFile, clearSmartEntryFile,
|
||
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
||
currentSubmitRiskWarning, currentSubmitRiskWarningNotes,
|
||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
|
||
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor,
|
||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||
focusExpenseRisk,
|
||
handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
|
||
handleModifyApplication,
|
||
handlePayRequest,
|
||
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||
isMajorExpenseRisk,
|
||
hasExpenseRiskIndicator,
|
||
hasExpenseRiskOrAbnormal,
|
||
triggerSmartEntryUpload, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
||
resolveExpenseRiskIndicatorTitle,
|
||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||
resolveRiskCardDomId, isHighlightedRiskCard,
|
||
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBadgeTone, riskOverrideBusy,
|
||
riskOverrideCancelText, riskOverrideConfirmIcon, riskOverrideConfirmText, riskOverrideConfirmTone,
|
||
riskOverrideDialogDescription, riskOverrideDialogOpen, riskOverrideDialogTitle,
|
||
riskOverrideGuidanceText, riskOverrideGuidanceTitle, riskOverrideIndexLabel,
|
||
requiresApprovalOpinion,
|
||
saveDetailNote, savingDetailNote, savingExpenseId,
|
||
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
|
||
smartEntryRecognitionBusy, smartEntryRecognitionText,
|
||
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
|
||
standardAdjustmentBusy,
|
||
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||
showBudgetAnalysis, showStageRiskAdvice,
|
||
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
|
||
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmSecondaryText, submitConfirmText,
|
||
submitExplainedRiskWarnings, submitRiskReviewWarnings, submitRiskWarnings, hasMissingSubmitRiskWarnings,
|
||
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
|
||
}
|
||
}
|
||
}
|
||
|
||
function hasBudgetApprovalWarning(request = {}) {
|
||
const flags = Array.isArray(request?.riskFlags)
|
||
? request.riskFlags
|
||
: Array.isArray(request?.risk_flags_json)
|
||
? request.risk_flags_json
|
||
: []
|
||
|
||
return flags.some((flag) => {
|
||
if (!flag || typeof flag !== 'object') {
|
||
return false
|
||
}
|
||
|
||
const routeDecision = flag.route_decision || flag.routeDecision || {}
|
||
const directBudgetResult = flag.budget_result || flag.budgetResult
|
||
const routeBudgetResult = routeDecision?.budget_result || routeDecision?.budgetResult
|
||
const budgetResult = routeBudgetResult || directBudgetResult
|
||
if (!budgetResult || typeof budgetResult !== 'object') {
|
||
return false
|
||
}
|
||
|
||
return budgetResultExceedsWarning(budgetResult)
|
||
})
|
||
}
|
||
|
||
function budgetResultExceedsWarning(budgetResult = {}) {
|
||
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
|
||
const context = budgetResult.budget_context && typeof budgetResult.budget_context === 'object'
|
||
? budgetResult.budget_context
|
||
: budgetResult.budgetContext && typeof budgetResult.budgetContext === 'object'
|
||
? budgetResult.budgetContext
|
||
: {}
|
||
const overBudgetAmount = parseBudgetNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
|
||
if (overBudgetAmount > 0) {
|
||
return true
|
||
}
|
||
|
||
const afterUsageRate = parseBudgetNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
|
||
const claimAmountRatio = parseBudgetNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
|
||
const warningThreshold = parseBudgetNumber(context.warning_threshold ?? context.warningThreshold, 80)
|
||
return Math.max(afterUsageRate, claimAmountRatio) >= warningThreshold
|
||
}
|
||
|
||
function parseBudgetNumber(value, fallback = 0) {
|
||
const number = Number(value)
|
||
return Number.isFinite(number) ? number : fallback
|
||
}
|