Files
X-Financial/web/src/views/scripts/travelRequestDetailSetup.js

668 lines
24 KiB
JavaScript
Raw Normal View History

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
}
}