import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' import { AI_APPLICATION_ACTION_SAVE_DRAFT, runAiApplicationPreviewAction } from '../../services/aiApplicationPreviewActions.js' import { calculateTravelReimbursement } from '../../services/reimbursements.js' import { canApproveBudgetExpenseApplications, canApproveLeaderExpenseClaims, canManageExpenseClaims, canReturnExpenseClaims, isCurrentDirectManagerForRequest, isCurrentRequestApplicant, isFinanceUser, isPlatformAdminUser } from '../../utils/accessControl.js' import { buildLeaderApprovalEvents, buildLeaderApprovalInfo } from '../../utils/applicationApproval.js' import { buildApplicationDetailFactItems, buildRelatedApplicationFactItems } from '../../utils/expenseApplicationDetail.js' import { buildRiskViewerContext } from '../../utils/riskVisibility.js' import { resolveProgressStepsForViewer } from '../../utils/requestProgressViewer.js' import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js' import { APPLICATION_TRANSPORT_MODE_OPTIONS, applyApplicationPolicyEstimateError, applyApplicationPolicyEstimateResult, buildApplicationPolicyEstimateRequest, normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js' import { EXPENSE_TYPE_OPTIONS, buildFallbackExpenseItems, buildFallbackProgressSteps, isApplicationDocumentRequest, rebuildExpenseItems, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder } from './travelRequestDetailExpenseModel.js' import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js' import { useTravelRequestDetailApprovalFlow } from './useTravelRequestDetailApprovalFlow.js' import { useTravelRequestDetailAttachmentPreview } from './useTravelRequestDetailAttachmentPreview.js' import { useTravelRequestDetailExpenseEditor } from './useTravelRequestDetailExpenseEditor.js' import { useTravelRequestDetailRiskSubmit } from './useTravelRequestDetailRiskSubmit.js' export function useTravelRequestDetailSetup(props, { emit }) { const { toast } = useToast() const { currentUser } = useSystemState() const expenseItems = ref([]) const expenseAttachmentMeta = reactive({}) const applicationDetailEditor = reactive({ fieldKey: '', draftValue: '', saving: false }) const riskFlagPreviewSnapshot = ref(null) let actionBusy = { value: false } const getActionBusy = () => Boolean(actionBusy?.value) 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 isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value)) const canModifyApplication = computed(() => ( isApplicationDocument.value && isEditableRequest.value && isCurrentApplicant.value )) 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 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 paymentFlow = useTravelRequestPaymentFlow({ request, currentUser, isApplicationDocument, isCurrentApplicant, toast, emit }) const attachmentPreview = useTravelRequestDetailAttachmentPreview({ request, expenseItems, expenseAttachmentMeta }) const riskSubmit = useTravelRequestDetailRiskSubmit({ request, expenseItems, expenseAttachmentMeta, riskFlagPreviewSnapshot, isApplicationDocument, isEditableRequest, isDraftRequest, isCurrentApplicant, canViewApprovalRiskAdvice, riskViewerContext, getActionBusy, toast, emit }) const approvalFlow = useTravelRequestDetailApprovalFlow({ request, isApplicationDocument, isDraftRequest, isArchivedRequest, isFinanceApprovalStage, isBudgetApprovalStage, canDeleteRequest, canReturnRequest, canApproveRequest, approvalRiskConfirmItems: riskSubmit.approvalRiskConfirmItems, canViewApprovalRiskAdvice, toast, emit }) const expenseEditor = useTravelRequestDetailExpenseEditor({ request, expenseItems, expenseAttachmentMeta, isEditableRequest, getActionBusy, toast, emit, attachmentPreviewOpen: attachmentPreview.attachmentPreviewOpen, buildAttachmentRiskNotice: attachmentPreview.buildAttachmentRiskNotice, closeAttachmentPreview: attachmentPreview.closeAttachmentPreview, refreshExpenseAttachmentMeta: attachmentPreview.refreshExpenseAttachmentMeta, resolveAttachmentMeta: attachmentPreview.resolveAttachmentMeta, resolveClaimRiskFlags: riskSubmit.resolveClaimRiskFlags, applyClaimRiskFlagsPayload: riskSubmit.applyClaimRiskFlagsPayload }) const { deletingAttachmentId, deletingExpenseId, savingExpenseId, smartEntryRecognitionBusy, uploadingExpenseId } = expenseEditor actionBusy = computed(() => Boolean(savingExpenseId.value) || riskSubmit.submitBusy.value || approvalFlow.deleteBusy.value || approvalFlow.returnBusy.value || approvalFlow.approveBusy.value || paymentFlow.payBusy.value || applicationDetailEditor.saving || 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 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 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 submitConfirmAmountDisplay = computed(() => isApplicationDocument.value ? (request.value.amountDisplay || expenseEditor.expenseTotal.value) : expenseEditor.expenseTotal.value ) const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value)) const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value)) const applicationEditEditableFields = ['reason', 'time', 'location', 'transportMode'] const applicationDetailEditableFactKeys = new Set([ 'reason', 'location', 'transport_mode', 'trip_start_time', 'trip_return_time', 'time' ]) 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] }) riskSubmit.resetSubmitWorkState() attachmentPreview.closeAttachmentPreview() } expenseEditor.resetExpenseWorkState() cancelApplicationDetailEditor() void attachmentPreview.syncExpenseAttachmentMeta() }, { immediate: true } ) watch( () => request.value.claimId, () => { riskSubmit.clearRiskFlagPreviewSnapshot() expenseEditor.resetSmartEntryRecognitionApplications() expenseEditor.bindSmartEntryRecognitionTask() }, { immediate: true } ) 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', applicationEditMode: true, editableFields: applicationEditEditableFields, 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 buildApplicationEditDraftPayload() { const claimId = String(request.value?.claimId || '').trim() const claimNo = String(request.value?.claimNo || request.value?.documentNo || request.value?.id || '').trim() return { draft_type: 'expense_application', document_type: 'expense_application', claim_id: claimId, claim_no: claimNo, status: String(request.value?.status || request.value?.approvalKey || '').trim(), approval_stage: String(request.value?.node || request.value?.approvalStage || '待提交').trim(), title: String(request.value?.typeLabel || '费用申请').trim(), application_edit_mode: true } } function normalizeApplicationDetailEditorValue(value = '') { const text = String(value || '').trim() return text === '待补充' ? '' : text } function resolveApplicationDetailFactValue(key = '') { const targetKey = String(key || '').trim() return String(applicationDetailFactItems.value.find((item) => item?.key === targetKey)?.value || '').trim() } function buildApplicationDetailDateRange(startDate = '', endDate = '') { const start = String(startDate || '').trim() const end = String(endDate || '').trim() if (!start && !end) return '' if (!start) return end if (!end || end === start) return start return `${start} 至 ${end}` } function resolveApplicationDetailDays(startDate = '', endDate = '') { const start = String(startDate || '').trim() const end = String(endDate || '').trim() if (!start || !end) return '' const startTime = new Date(`${start}T00:00:00`).getTime() const endTime = new Date(`${end}T00:00:00`).getTime() if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) { return '' } return `${Math.round((endTime - startTime) / 86400000) + 1}天` } function canEditApplicationDetailItem(item = {}) { return ( canModifyApplication.value && applicationDetailEditableFactKeys.has(String(item?.key || '').trim()) ) } function isApplicationDetailEditing(item = {}) { return String(applicationDetailEditor.fieldKey || '') === String(item?.key || '') } function resolveApplicationDetailEditorControl(item = {}) { const key = String(item?.key || '').trim() if (['trip_start_time', 'trip_return_time', 'time'].includes(key)) { return 'date' } if (key === 'transport_mode') { return 'select' } return 'text' } function openApplicationDetailEditor(item = {}) { if (!canEditApplicationDetailItem(item) || applicationDetailEditor.saving) { return } applicationDetailEditor.fieldKey = String(item.key || '').trim() applicationDetailEditor.draftValue = normalizeApplicationDetailEditorValue(item.value) } function cancelApplicationDetailEditor() { applicationDetailEditor.fieldKey = '' applicationDetailEditor.draftValue = '' } function buildEditedApplicationPreview(item = {}) { const key = String(item?.key || '').trim() const nextValue = normalizeApplicationDetailEditorValue(applicationDetailEditor.draftValue) const preview = buildApplicationEditPreview() const fields = { ...(preview.fields || {}) } if (key === 'reason') { fields.reason = nextValue } else if (key === 'location') { fields.location = nextValue } else if (key === 'transport_mode') { fields.transportMode = nextValue } else if (key === 'time') { fields.time = nextValue } else if (key === 'trip_start_time' || key === 'trip_return_time') { const startDate = key === 'trip_start_time' ? nextValue : resolveApplicationDetailFactValue('trip_start_time') const endDate = key === 'trip_return_time' ? nextValue : resolveApplicationDetailFactValue('trip_return_time') fields.time = buildApplicationDetailDateRange(startDate, endDate) fields.days = resolveApplicationDetailDays(startDate, endDate) || fields.days } return normalizeApplicationPreview({ ...preview, fields }) } async function refreshEditedApplicationPreviewEstimate(preview = {}) { const estimateRequest = buildApplicationPolicyEstimateRequest(preview, currentUser.value || {}) if (!estimateRequest.canCalculate) { return preview } try { const result = await calculateTravelReimbursement(estimateRequest.payload) return applyApplicationPolicyEstimateResult(preview, result, currentUser.value || {}) } catch (error) { return applyApplicationPolicyEstimateError(preview, error, currentUser.value || {}) } } async function saveApplicationDetailEdit(item = {}) { if (!isApplicationDetailEditing(item) || applicationDetailEditor.saving) { return } if (!String(request.value?.claimId || '').trim()) { toast('当前申请缺少单据标识,暂不能修改。') return } applicationDetailEditor.saving = true try { const preview = await refreshEditedApplicationPreviewEstimate(buildEditedApplicationPreview(item)) const payload = await runAiApplicationPreviewAction({ actionType: AI_APPLICATION_ACTION_SAVE_DRAFT, applicationPreview: preview, currentUser: currentUser.value || {}, draftPayload: buildApplicationEditDraftPayload() }) const draftPayload = payload?.result?.draft_payload || payload?.draft_payload || {} emit('request-updated', { claimId: String(draftPayload.claim_id || request.value.claimId || '').trim(), claimNo: String(draftPayload.claim_no || request.value.claimNo || request.value.documentNo || '').trim(), status: String(draftPayload.status || request.value.status || '').trim(), approvalStage: String(draftPayload.approval_stage || request.value.node || '').trim() }) cancelApplicationDetailEditor() toast('申请信息已更新。') } catch (error) { toast(error?.message || '申请信息更新失败,请稍后重试。') } finally { applicationDetailEditor.saving = false } } onBeforeUnmount(() => { riskSubmit.disposeRiskSubmit() expenseEditor.disposeExpenseEditor() attachmentPreview.closeAttachmentPreview() }) return { emit, actionBusy, ...attachmentPreview, ...approvalFlow, ...expenseEditor, ...paymentFlow, ...riskSubmit, APPLICATION_TRANSPORT_MODE_OPTIONS, applicationDetailEditor, applicationDetailFactItems, relatedApplicationFactItems, canEditApplicationDetailItem, canDeleteRequest, canManageCurrentClaim, canModifyApplication, canOpenAiEntry, canApproveRequest, canReturnRequest, currentProgressRingMotion, expenseItems, expenseTypeOptions: EXPENSE_TYPE_OPTIONS, hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, heroFactItems, isApplicationDocument, isApplicationDetailEditing, isDraftRequest, isEditableRequest, isTravelRequest, leaderApprovalEvents, leaderApprovalReadonlyMeta, profile, progressSteps, request, cancelApplicationDetailEditor, openApplicationDetailEditor, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveApplicationDetailEditorControl, saveApplicationDetailEdit, showApplicationLeaderOpinion, showBudgetAnalysis, showStageRiskAdvice, submitConfirmAmountDisplay } }