1721 lines
56 KiB
JavaScript
1721 lines
56 KiB
JavaScript
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||
|
||
import { useSystemState } from '../../composables/useSystemState.js'
|
||
import { useToast } from '../../composables/useToast.js'
|
||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||
import ReturnReasonDialog from '../../components/shared/ReturnReasonDialog.vue'
|
||
import {
|
||
approveExpenseClaim,
|
||
createExpenseClaimItem,
|
||
deleteExpenseClaimItem,
|
||
deleteExpenseClaimItemAttachment,
|
||
deleteExpenseClaim,
|
||
fetchExpenseClaimItemAttachmentMeta,
|
||
fetchExpenseClaimItemAttachmentPreview,
|
||
returnExpenseClaim,
|
||
submitExpenseClaim,
|
||
uploadExpenseClaimItemAttachment,
|
||
updateExpenseClaim,
|
||
updateExpenseClaimItem
|
||
} from '../../services/reimbursements.js'
|
||
import {
|
||
canApproveLeaderExpenseClaims,
|
||
canManageExpenseClaims,
|
||
canReturnExpenseClaims,
|
||
isFinanceUser
|
||
} from '../../utils/accessControl.js'
|
||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||
import {
|
||
buildAiAdviceViewModel,
|
||
buildAttachmentInsightViewModel,
|
||
buildAttachmentRiskCards
|
||
} from './travelRequestDetailInsights.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: 'entertainment', 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', 'ride_ticket'])
|
||
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()()·]{2,40}$/
|
||
|
||
function parseCurrency(value) {
|
||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||
}
|
||
|
||
function formatCurrency(value) {
|
||
return new Intl.NumberFormat('zh-CN', {
|
||
style: 'currency',
|
||
currency: 'CNY',
|
||
minimumFractionDigits: 0,
|
||
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
||
}).format(value)
|
||
}
|
||
|
||
function normalizeExpenseType(value) {
|
||
return String(value || '').trim() || 'other'
|
||
}
|
||
|
||
function resolveExpenseTypeLabel(value) {
|
||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||
}
|
||
|
||
function isSystemGeneratedExpenseItemSource(source) {
|
||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
|
||
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||
}
|
||
|
||
function isLocationRequiredExpenseType(value) {
|
||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||
}
|
||
|
||
function resolveLocationSummaryLabel(value) {
|
||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||
}
|
||
|
||
function resolveLocationDisplay(value, expenseType) {
|
||
if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) {
|
||
return '非必填'
|
||
}
|
||
|
||
return isPlaceholderValue(value) ? '待补充' : value
|
||
}
|
||
|
||
function isRouteDescriptionExpenseType(value) {
|
||
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||
}
|
||
|
||
function isValidRouteDescription(value) {
|
||
const text = String(value || '').trim()
|
||
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
|
||
}
|
||
|
||
function resolveExpenseReasonPlaceholder(itemType) {
|
||
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'
|
||
}
|
||
|
||
function resolveExpenseReasonHelper(itemType) {
|
||
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地' : '业务报销说明'
|
||
}
|
||
|
||
function buildFallbackProgressSteps() {
|
||
return [
|
||
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
|
||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||
{ index: 5, label: '财务审批', time: '待处理' },
|
||
{ index: 6, label: '归档入账', time: '待处理' }
|
||
]
|
||
}
|
||
|
||
function buildFallbackExpenseItems(request) {
|
||
return [
|
||
buildExpenseItemViewModel({
|
||
id: 'fallback-1',
|
||
itemDate: '',
|
||
itemType: request.typeCode || 'other',
|
||
itemReason: request.reason,
|
||
itemLocation: request.sceneTarget,
|
||
itemAmount: parseCurrency(request.amountDisplay),
|
||
invoiceId: '',
|
||
time: '待补充',
|
||
dayLabel: request.detailVariant === 'travel' ? '出行日' : '业务发生日',
|
||
name: request.typeLabel,
|
||
category: request.typeLabel,
|
||
desc: request.reason,
|
||
detail: resolveLocationDisplay(request.sceneTarget, request.typeCode),
|
||
amount: request.amountDisplay,
|
||
status: '待补充',
|
||
tone: 'bad',
|
||
attachmentStatus: '待上传',
|
||
attachmentHint: '请在此单据中继续补充附件',
|
||
attachmentTone: 'missing',
|
||
attachments: [],
|
||
riskLabel: '待补材料',
|
||
riskText: request.riskSummary,
|
||
riskTone: 'medium'
|
||
}, 0, request)
|
||
]
|
||
}
|
||
|
||
function isPlaceholderValue(value) {
|
||
const text = String(value || '').trim()
|
||
if (!text) {
|
||
return true
|
||
}
|
||
|
||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||
}
|
||
|
||
function normalizeDetailNoteDraftValue(value) {
|
||
const text = String(value || '').trim()
|
||
return isPlaceholderValue(text) ? '' : text
|
||
}
|
||
|
||
function isValidIsoDate(value) {
|
||
const normalized = String(value || '').trim()
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||
return false
|
||
}
|
||
|
||
const [yearText, monthText, dayText] = normalized.split('-')
|
||
const year = Number(yearText)
|
||
const month = Number(monthText)
|
||
const day = Number(dayText)
|
||
|
||
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
|
||
return false
|
||
}
|
||
|
||
const candidate = new Date(Date.UTC(year, month - 1, day))
|
||
return (
|
||
candidate.getUTCFullYear() === year &&
|
||
candidate.getUTCMonth() === month - 1 &&
|
||
candidate.getUTCDate() === day
|
||
)
|
||
}
|
||
|
||
function normalizeIsoDateValue(value) {
|
||
const normalized = String(value || '').trim()
|
||
if (isValidIsoDate(normalized)) {
|
||
return normalized
|
||
}
|
||
|
||
const match = normalized.match(/^(\d{4}-\d{2}-\d{2})/)
|
||
if (match && isValidIsoDate(match[1])) {
|
||
return match[1]
|
||
}
|
||
|
||
const candidate = value instanceof Date ? value : new Date(normalized)
|
||
if (Number.isNaN(candidate.getTime())) {
|
||
return ''
|
||
}
|
||
|
||
const year = candidate.getFullYear()
|
||
const month = String(candidate.getMonth() + 1).padStart(2, '0')
|
||
const day = String(candidate.getDate()).padStart(2, '0')
|
||
return `${year}-${month}-${day}`
|
||
}
|
||
|
||
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 resolveExpenseUploadHint(value) {
|
||
const normalized = String(value || '').trim()
|
||
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
||
}
|
||
|
||
function extractAttachmentDisplayName(value) {
|
||
const normalized = String(value || '').trim()
|
||
if (!normalized) {
|
||
return ''
|
||
}
|
||
|
||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||
}
|
||
|
||
function resolveExpenseItemViewId(source, index, requestModel) {
|
||
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
|
||
}
|
||
|
||
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 buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
|
||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||
const itemAmount = parseCurrency(source?.itemAmount ?? source?.item_amount)
|
||
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
|
||
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
|
||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
|
||
const riskText = String(source?.riskText || '').trim()
|
||
const filledAt = formatExpenseFilledTime(
|
||
source?.filledAt
|
||
|| source?.filled_at
|
||
|| source?.createdAt
|
||
|| source?.created_at
|
||
)
|
||
|
||
return {
|
||
id,
|
||
itemDate,
|
||
itemType,
|
||
itemReason,
|
||
itemLocation,
|
||
itemAmount,
|
||
invoiceId,
|
||
isSystemGenerated,
|
||
time: itemDate || '待补充',
|
||
filledAt: filledAt || '待同步',
|
||
dayLabel: resolveExpenseTimeLabel({
|
||
id,
|
||
itemType,
|
||
isSystemGenerated,
|
||
requestModel,
|
||
travelTimeLabelMap
|
||
}),
|
||
name: resolveExpenseTypeLabel(itemType),
|
||
category: resolveExpenseTypeLabel(itemType),
|
||
desc: itemReason || '待补充',
|
||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||
amount: amountDisplay,
|
||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||
attachments,
|
||
riskLabel: String(source?.riskLabel || '').trim() || '无',
|
||
riskText,
|
||
riskTone: String(source?.riskTone || '').trim() || 'low'
|
||
}
|
||
}
|
||
|
||
function rebuildExpenseItems(items, requestModel) {
|
||
const sortedItems = [...items]
|
||
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
|
||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
|
||
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
|
||
}
|
||
|
||
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 buildDraftBlockingIssues(request, expenseItems) {
|
||
const issues = []
|
||
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||
|
||
if (isPlaceholderValue(request.profileName)) {
|
||
issues.push('申请人未完善')
|
||
}
|
||
if (isPlaceholderValue(request.typeLabel)) {
|
||
issues.push('报销类型未完善')
|
||
}
|
||
if (isPlaceholderValue(request.reason)) {
|
||
issues.push('报销事由未完善')
|
||
}
|
||
if (locationRequired && isPlaceholderValue(request.location)) {
|
||
issues.push('业务地点未完善')
|
||
}
|
||
if (isPlaceholderValue(request.occurredDisplay)) {
|
||
issues.push('发生时间未完善')
|
||
}
|
||
if (!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) {
|
||
issues.push('报销金额未完善')
|
||
}
|
||
if (!expenseItems.length) {
|
||
issues.push('费用明细不能为空')
|
||
}
|
||
|
||
expenseItems.forEach((item, index) => {
|
||
buildExpenseDraftIssues(item).forEach((issue) => {
|
||
issues.push(`费用明细第 ${index + 1} 条${issue}`)
|
||
})
|
||
})
|
||
|
||
return [...new Set(issues)]
|
||
}
|
||
|
||
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,
|
||
ReturnReasonDialog
|
||
},
|
||
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 creatingExpense = ref(false)
|
||
const uploadingExpenseId = ref('')
|
||
const deletingAttachmentId = ref('')
|
||
const deletingExpenseId = ref('')
|
||
const pendingUploadExpenseId = ref('')
|
||
const submitBusy = ref(false)
|
||
const submitConfirmDialogOpen = ref(false)
|
||
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 leaderOpinion = ref('')
|
||
const expenseUploadInput = ref(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: '',
|
||
invoiceId: ''
|
||
})
|
||
const detailNoteEditor = ref('')
|
||
const savingDetailNote = ref(false)
|
||
|
||
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 isTravelRequest = computed(() => request.value.detailVariant === 'travel')
|
||
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
|
||
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
|
||
const canOpenAiEntry = computed(() => isEditableRequest.value)
|
||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||
const canDeleteRequest = computed(() => isEditableRequest.value || canManageCurrentClaim.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 canReturnRequest = computed(() =>
|
||
canReturnExpenseClaims(currentUser.value)
|
||
&& request.value.approvalKey === 'in_progress'
|
||
&& Boolean(request.value.claimId)
|
||
)
|
||
const canApproveRequest = computed(() =>
|
||
Boolean(props.approvalMode)
|
||
&& request.value.approvalKey === 'in_progress'
|
||
&& Boolean(request.value.claimId)
|
||
&& (
|
||
(
|
||
isDirectManagerApprovalStage.value
|
||
&& canApproveLeaderExpenseClaims(currentUser.value)
|
||
)
|
||
|| (
|
||
isFinanceApprovalStage.value
|
||
&& isFinanceUser(currentUser.value)
|
||
)
|
||
)
|
||
)
|
||
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
|
||
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
|
||
const approvalOpinionPlaceholder = computed(() =>
|
||
isFinanceApprovalStage.value
|
||
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
|
||
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
|
||
)
|
||
const approvalOpinionHint = computed(() =>
|
||
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
|
||
)
|
||
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
|
||
const approvalConfirmDescription = computed(() =>
|
||
isFinanceApprovalStage.value
|
||
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
|
||
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
|
||
)
|
||
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
|
||
const approvalSuccessToast = computed(() =>
|
||
isFinanceApprovalStage.value
|
||
? `${request.value.id} 已完成财务终审,进入归档入账。`
|
||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||
)
|
||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||
const deleteDialogDescription = computed(() =>
|
||
isDraftRequest.value
|
||
? '删除后该草稿及其当前费用明细将不可恢复,请确认本次操作。'
|
||
: '删除后该报销单及费用明细将不可恢复,请确认本次操作。'
|
||
)
|
||
const actionBusy = computed(() =>
|
||
Boolean(savingExpenseId.value)
|
||
|| submitBusy.value
|
||
|| deleteBusy.value
|
||
|| returnBusy.value
|
||
|| approveBusy.value
|
||
|| creatingExpense.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]
|
||
})
|
||
closeAttachmentPreview()
|
||
}
|
||
pendingUploadExpenseId.value = ''
|
||
uploadingExpenseId.value = ''
|
||
deletingExpenseId.value = ''
|
||
editingExpenseId.value = ''
|
||
void syncExpenseAttachmentMeta()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
const heroFactItems = computed(() => [
|
||
{
|
||
key: 'document',
|
||
label: '报销单号',
|
||
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: '报销金额',
|
||
value: request.value.amountDisplay,
|
||
icon: '',
|
||
valueClass: 'amount'
|
||
},
|
||
{
|
||
key: 'type',
|
||
label: isTravelRequest.value ? '差旅类型' : '报销类型',
|
||
value: request.value.typeLabel,
|
||
icon: '',
|
||
valueClass: ''
|
||
},
|
||
{
|
||
key: 'status',
|
||
label: '当前状态',
|
||
value: request.value.node,
|
||
icon: '',
|
||
valueClass: 'status'
|
||
}
|
||
])
|
||
|
||
const progressSteps = computed(() =>
|
||
Array.isArray(request.value.progressSteps) && request.value.progressSteps.length
|
||
? request.value.progressSteps
|
||
: buildFallbackProgressSteps()
|
||
)
|
||
|
||
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) => sum + Number(item.itemAmount || 0), 0)
|
||
return formatCurrency(total)
|
||
})
|
||
|
||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||
const expenseTableColumnCount = computed(
|
||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||
)
|
||
const canEditDetailNote = computed(() => isDraftRequest.value)
|
||
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
|
||
const detailNote = computed(() => {
|
||
if (detailNoteSource.value) {
|
||
return detailNoteSource.value
|
||
}
|
||
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
||
})
|
||
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
||
watch(
|
||
() => [request.value.claimId, detailNoteSource.value],
|
||
([, nextNote]) => {
|
||
detailNoteEditor.value = nextNote
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
const draftBlockingIssues = computed(() =>
|
||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||
)
|
||
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
||
const attachmentPreviewEntries = computed(() =>
|
||
expenseItems.value
|
||
.filter((item) => item.invoiceId)
|
||
.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 resolveAttachmentDisplayName(item) {
|
||
const metadata = resolveAttachmentMeta(item)
|
||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||
}
|
||
|
||
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}`
|
||
}
|
||
|
||
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) {
|
||
const metadata = resolveAttachmentMeta(item)
|
||
return Boolean(item.invoiceId && metadata?.previewable !== false)
|
||
}
|
||
|
||
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 (!item.invoiceId) {
|
||
return null
|
||
}
|
||
|
||
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: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
|
||
headline: analysis.headline || 'AI提示',
|
||
summary: analysis.summary || '',
|
||
points: Array.isArray(analysis.points) ? analysis.points : [],
|
||
suggestion: analysis.suggestion || ''
|
||
}
|
||
}
|
||
|
||
return {
|
||
label: '已上传',
|
||
tone: 'low',
|
||
headline: 'AI提示:附件已上传',
|
||
summary: '附件已成功保存,当前可继续查看原图并人工核对票据内容。',
|
||
points: [],
|
||
suggestion: ''
|
||
}
|
||
}
|
||
|
||
function showExpenseRisk(item) {
|
||
return Boolean(resolveExpenseRiskState(item))
|
||
}
|
||
|
||
const aiAdvice = computed(() => {
|
||
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||
const riskCards = buildAttachmentRiskCards({
|
||
expenseItems: expenseItems.value,
|
||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
|
||
})
|
||
|
||
return buildAiAdviceViewModel({
|
||
completionItems,
|
||
riskCards
|
||
})
|
||
})
|
||
|
||
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 startExpenseEdit(item) {
|
||
if (!isEditableRequest.value || actionBusy.value) {
|
||
return
|
||
}
|
||
if (item?.isSystemGenerated) {
|
||
toast('系统自动计算的补贴行不能手动编辑。')
|
||
return
|
||
}
|
||
|
||
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 || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail)
|
||
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
||
expenseEditor.invoiceId = item.invoiceId || ''
|
||
}
|
||
|
||
function cancelExpenseEdit() {
|
||
editingExpenseId.value = ''
|
||
}
|
||
|
||
function validateExpenseEditor() {
|
||
if (!isValidIsoDate(expenseEditor.itemDate)) {
|
||
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
|
||
}
|
||
if (isPlaceholderValue(expenseEditor.itemType)) {
|
||
return '请选择费用项目。'
|
||
}
|
||
if (isPlaceholderValue(expenseEditor.itemReason)) {
|
||
return '请输入费用说明。'
|
||
}
|
||
if (
|
||
isRouteDescriptionExpenseType(expenseEditor.itemType)
|
||
&& !isValidRouteDescription(expenseEditor.itemReason)
|
||
) {
|
||
return '行程说明格式应为“始发地-目的地”,例如:广州南-北京南。'
|
||
}
|
||
|
||
const amount = Number(expenseEditor.itemAmount)
|
||
if (!Number.isFinite(amount) || amount <= 0) {
|
||
return '请输入大于 0 的费用金额。'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
async function handleAddExpenseItem() {
|
||
if (!isEditableRequest.value || actionBusy.value) {
|
||
return
|
||
}
|
||
|
||
if (!request.value.claimId) {
|
||
toast('当前草稿缺少 claimId,暂时无法新增费用明细。')
|
||
return
|
||
}
|
||
|
||
creatingExpense.value = true
|
||
try {
|
||
const existingIds = new Set(expenseItems.value.map((item) => item.id))
|
||
const claim = await createExpenseClaimItem(request.value.claimId, {})
|
||
const createdItem = Array.isArray(claim?.items)
|
||
? claim.items.find((entry) => !existingIds.has(String(entry?.id || '')))
|
||
: null
|
||
|
||
if (!createdItem) {
|
||
throw new Error('新增费用明细失败,请稍后重试。')
|
||
}
|
||
|
||
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
|
||
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
||
creatingExpense.value = false
|
||
startExpenseEdit(nextItem)
|
||
toast('已新增一条费用明细,请继续填写。')
|
||
} catch (error) {
|
||
toast(error?.message || '新增费用明细失败,请稍后重试。')
|
||
} finally {
|
||
creatingExpense.value = false
|
||
}
|
||
}
|
||
|
||
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) {
|
||
metadata = await refreshExpenseAttachmentMeta(item.id)
|
||
}
|
||
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)
|
||
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||
const itemPatch = {
|
||
invoiceId: String(payload?.invoice_id || '').trim(),
|
||
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
|
||
}
|
||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||
itemPatch.itemAmount = recognizedItemAmount
|
||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||
}
|
||
applyLocalExpenseItemPatch(item.id, {
|
||
...itemPatch
|
||
})
|
||
if (editingExpenseId.value === item.id) {
|
||
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
|
||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||
expenseEditor.itemAmount = String(recognizedItemAmount)
|
||
}
|
||
}
|
||
|
||
emit('request-updated', { claimId: request.value.claimId })
|
||
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
|
||
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
|
||
} catch (error) {
|
||
toast(error?.message || '附件上传失败,请稍后重试。')
|
||
} 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)
|
||
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.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()
|
||
await updateExpenseClaimItem(request.value.claimId, item.id, {
|
||
item_date: expenseEditor.itemDate,
|
||
item_type: expenseEditor.itemType,
|
||
item_reason: expenseEditor.itemReason.trim(),
|
||
item_location: preservedLocation,
|
||
item_amount: Number(expenseEditor.itemAmount),
|
||
invoice_id: nextInvoiceId
|
||
})
|
||
applyLocalExpenseItemPatch(item.id, {
|
||
itemDate: expenseEditor.itemDate,
|
||
itemType: expenseEditor.itemType,
|
||
itemReason: expenseEditor.itemReason.trim(),
|
||
itemLocation: preservedLocation,
|
||
itemAmount: Number(expenseEditor.itemAmount),
|
||
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 = ''
|
||
}
|
||
}
|
||
|
||
function handleSubmit() {
|
||
if (!request.value.claimId) {
|
||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||
return
|
||
}
|
||
|
||
if (!canSubmit.value) {
|
||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||
return
|
||
}
|
||
|
||
submitConfirmDialogOpen.value = true
|
||
}
|
||
|
||
function closeSubmitConfirmDialog() {
|
||
if (submitBusy.value) {
|
||
return
|
||
}
|
||
|
||
submitConfirmDialogOpen.value = false
|
||
}
|
||
|
||
async function confirmSubmitRequest() {
|
||
if (!request.value.claimId) {
|
||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||
submitConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
if (!canSubmit.value) {
|
||
toast(draftBlockingIssues.value[0] || '请先补全草稿信息,再提交审批。')
|
||
submitConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
submitBusy.value = true
|
||
try {
|
||
const payload = await submitExpenseClaim(request.value.claimId)
|
||
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
||
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
||
if (claimStatus === 'submitted') {
|
||
toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||
} else if (claimStatus === 'supplement') {
|
||
toast(`${request.value.id} AI预审未通过,已转待补充。`)
|
||
} else {
|
||
toast(`${request.value.id} 提交结果已更新。`)
|
||
}
|
||
submitConfirmDialogOpen.value = false
|
||
emit('request-updated', { claimId: request.value.claimId })
|
||
} catch (error) {
|
||
toast(error?.message || '提交审批失败,请稍后重试。')
|
||
} finally {
|
||
submitBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function handleDeleteRequest() {
|
||
if (!request.value.claimId) {
|
||
toast('当前单据缺少 claimId,暂时无法删除。')
|
||
return
|
||
}
|
||
|
||
if (!canDeleteRequest.value) {
|
||
toast('当前单据已进入流程,只有财务人员或高级管理人员可以删除。')
|
||
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} 报销单已删除。`)
|
||
emit('request-deleted', { claimId: request.value.claimId })
|
||
} 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
|
||
}
|
||
|
||
approveConfirmDialogOpen.value = true
|
||
}
|
||
|
||
function closeApproveConfirmDialog() {
|
||
if (approveBusy.value) {
|
||
return
|
||
}
|
||
|
||
approveConfirmDialogOpen.value = false
|
||
}
|
||
|
||
async function confirmApproveRequest() {
|
||
if (!request.value.claimId) {
|
||
toast('当前单据缺少 claimId,暂时无法审批通过。')
|
||
approveConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
if (!canApproveRequest.value) {
|
||
toast('当前节点暂不支持审批通过。')
|
||
approveConfirmDialogOpen.value = false
|
||
return
|
||
}
|
||
|
||
approveBusy.value = true
|
||
try {
|
||
await approveExpenseClaim(request.value.claimId, {
|
||
opinion: leaderOpinion.value.trim()
|
||
})
|
||
approveConfirmDialogOpen.value = false
|
||
leaderOpinion.value = ''
|
||
toast(approvalSuccessToast.value)
|
||
emit('request-updated', { claimId: request.value.claimId })
|
||
} catch (error) {
|
||
toast(error?.message || '审批通过失败,请稍后重试。')
|
||
} finally {
|
||
approveBusy.value = false
|
||
}
|
||
}
|
||
|
||
function openAiEntry() {
|
||
if (!canOpenAiEntry.value) {
|
||
return
|
||
}
|
||
|
||
emit('openAssistant', {
|
||
source: 'detail',
|
||
prompt: '',
|
||
request: request.value,
|
||
restoreLatestConversation: true
|
||
})
|
||
}
|
||
|
||
onBeforeUnmount(() => {
|
||
closeAttachmentPreview()
|
||
})
|
||
|
||
return {
|
||
emit,
|
||
actionBusy,
|
||
aiAdvice,
|
||
attachmentPreviewError,
|
||
attachmentPreviewIndexLabel,
|
||
attachmentPreviewLoading,
|
||
attachmentPreviewMediaType,
|
||
attachmentPreviewName,
|
||
attachmentPreviewOpen,
|
||
attachmentPreviewUrl,
|
||
approveBusy,
|
||
approveConfirmDialogOpen,
|
||
approvalConfirmBadge,
|
||
approvalConfirmDescription,
|
||
approvalNextStage,
|
||
approvalOpinionHint,
|
||
approvalOpinionPlaceholder,
|
||
approvalOpinionTitle,
|
||
canDeleteRequest,
|
||
canManageCurrentClaim,
|
||
canNavigateAttachmentPreview,
|
||
canOpenAiEntry,
|
||
canApproveRequest,
|
||
canReturnRequest,
|
||
canSubmit,
|
||
canPreviewAttachment,
|
||
closeApproveConfirmDialog,
|
||
closeDeleteDialog,
|
||
closeAttachmentPreview,
|
||
closeSubmitConfirmDialog,
|
||
closeReturnDialog,
|
||
confirmApproveRequest,
|
||
confirmDeleteRequest,
|
||
confirmSubmitRequest,
|
||
confirmReturnRequest,
|
||
currentAttachmentPreviewInsight,
|
||
currentAttachmentPreviewRiskCards,
|
||
currentProgressRingMotion,
|
||
canEditDetailNote,
|
||
deleteActionLabel,
|
||
deleteBusy,
|
||
deleteDialogDescription,
|
||
deleteDialogOpen,
|
||
deleteDialogTitle,
|
||
deletingAttachmentId,
|
||
deletingExpenseId,
|
||
detailNote,
|
||
detailNoteDirty,
|
||
detailNoteEditor,
|
||
draftBlockingIssues,
|
||
editingExpenseId,
|
||
creatingExpense,
|
||
expenseEditor,
|
||
expenseItems,
|
||
expenseTableColumnCount,
|
||
expenseTotal,
|
||
expenseUploadInput,
|
||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||
handleAddExpenseItem,
|
||
handleApproveRequest,
|
||
handleDeleteRequest,
|
||
handleExpenseFileChange,
|
||
handleReturnRequest,
|
||
handleSubmit,
|
||
heroFactItems,
|
||
isDraftRequest,
|
||
isEditableRequest,
|
||
isTravelRequest,
|
||
openAiEntry,
|
||
openAttachmentPreview,
|
||
goToNextAttachmentPreview,
|
||
goToPreviousAttachmentPreview,
|
||
profile,
|
||
progressSteps,
|
||
request,
|
||
leaderOpinion,
|
||
removeExpenseAttachment,
|
||
removeExpenseItem,
|
||
resetDetailNote,
|
||
resolveAttachmentDisplayName,
|
||
resolveAttachmentPreviewTitle,
|
||
resolveAttachmentRecognition,
|
||
resolveExpenseReasonHelper,
|
||
resolveExpenseReasonPlaceholder,
|
||
resolveExpenseRiskState,
|
||
resolveExpenseIssues,
|
||
returnBusy,
|
||
returnDialogOpen,
|
||
saveDetailNote,
|
||
savingDetailNote,
|
||
savingExpenseId,
|
||
showLeaderApprovalPanel,
|
||
showExpenseRisk,
|
||
startExpenseEdit,
|
||
submitBusy,
|
||
submitConfirmDialogOpen,
|
||
triggerExpenseUpload,
|
||
uploadedExpenseCount,
|
||
uploadingExpenseId,
|
||
cancelExpenseEdit,
|
||
saveExpenseEdit
|
||
}
|
||
}
|
||
}
|