Files
X-Financial/web/src/views/scripts/TravelRequestDetailView.js
caoxiaozhu 0cda750ff0 feat(web): AI 工作台会话与文档卡片渲染增强
- aiConversationHtmlRenderer 识别单据记录类表格并渲染为卡片列表,新增删除申请单详情的禁用占位链接
- aiWorkbenchConversationStore 增加草稿删除后会话链接失效处理,避免点击已删除单据跳转
- aiApplicationPreviewActions 调整提交/草稿调用路径,PersonalWorkbenchAiMode 接入新的会话存储与渲染
- ConfirmDialog/TravelRequestDeleteDialog/useAppShell/AppShellRouteView 配套适配,同步更新相关前端测试
2026-06-20 21:44:16 +08:00

2882 lines
104 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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(() =>
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
? request.value.progressSteps
: buildFallbackProgressSteps(request.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
}