feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
@@ -94,6 +94,223 @@ import {
|
||||
} from './travelRequestDetailAdviceModel.js'
|
||||
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
|
||||
|
||||
const SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS = 10 * 60 * 1000
|
||||
const smartEntryRecognitionTasks = new Map()
|
||||
let smartEntryRecognitionTaskSeq = 0
|
||||
|
||||
function normalizeSmartEntryClaimId(claimId) {
|
||||
return String(claimId || '').trim()
|
||||
}
|
||||
|
||||
function buildRecognizedExpenseItemPatch(payload, fileName = '') {
|
||||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||||
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
|
||||
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
|
||||
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
|
||||
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
|
||||
const itemPatch = {
|
||||
invoiceId: String(payload?.invoice_id || '').trim(),
|
||||
attachmentHint: String(payload?.attachment?.file_name || fileName || '').trim()
|
||||
}
|
||||
if (recognizedItemDate) {
|
||||
itemPatch.itemDate = recognizedItemDate
|
||||
}
|
||||
if (recognizedItemType) {
|
||||
itemPatch.itemType = recognizedItemType
|
||||
}
|
||||
if (recognizedItemReason) {
|
||||
itemPatch.itemReason = recognizedItemReason
|
||||
}
|
||||
if (recognizedItemLocation) {
|
||||
itemPatch.itemLocation = recognizedItemLocation
|
||||
}
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
itemPatch.itemAmount = recognizedItemAmount
|
||||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||||
}
|
||||
return itemPatch
|
||||
}
|
||||
|
||||
function buildSmartEntryRecognitionSnapshot(task) {
|
||||
if (!task) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
claimId: task.claimId,
|
||||
busy: task.busy,
|
||||
total: task.total,
|
||||
current: task.current,
|
||||
completed: task.completed,
|
||||
successCount: task.successCount,
|
||||
failedCount: task.failedCount,
|
||||
uploadingItemId: task.uploadingItemId,
|
||||
fileName: task.fileName,
|
||||
status: task.status,
|
||||
payloads: [...task.payloads],
|
||||
errors: [...task.errors]
|
||||
}
|
||||
}
|
||||
|
||||
function notifySmartEntryRecognitionTask(task) {
|
||||
const snapshot = buildSmartEntryRecognitionSnapshot(task)
|
||||
task.listeners.forEach((listener) => {
|
||||
try {
|
||||
listener(snapshot)
|
||||
} catch (error) {
|
||||
console.error('同步附件识别状态失败', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleSmartEntryRecognitionTaskCleanup(task) {
|
||||
if (task.cleanupTimer) {
|
||||
clearTimeout(task.cleanupTimer)
|
||||
}
|
||||
task.cleanupTimer = globalThis.setTimeout(() => {
|
||||
const currentTask = smartEntryRecognitionTasks.get(task.claimId)
|
||||
if (currentTask?.id === task.id && !currentTask.busy) {
|
||||
smartEntryRecognitionTasks.delete(task.claimId)
|
||||
}
|
||||
}, SMART_ENTRY_RECOGNITION_TASK_RETENTION_MS)
|
||||
}
|
||||
|
||||
function getSmartEntryRecognitionTask(claimId) {
|
||||
return smartEntryRecognitionTasks.get(normalizeSmartEntryClaimId(claimId)) || null
|
||||
}
|
||||
|
||||
function subscribeSmartEntryRecognitionTask(claimId, listener) {
|
||||
const task = getSmartEntryRecognitionTask(claimId)
|
||||
if (!task) {
|
||||
listener(null)
|
||||
return () => {}
|
||||
}
|
||||
|
||||
task.listeners.add(listener)
|
||||
listener(buildSmartEntryRecognitionSnapshot(task))
|
||||
return () => {
|
||||
task.listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSmartEntryTaskAvailableItems(itemSnapshots) {
|
||||
return (Array.isArray(itemSnapshots) ? itemSnapshots : [])
|
||||
.filter((item) => item && !item.isSystemGenerated && !item.invoiceId)
|
||||
.map((item) => ({ id: String(item.id || '').trim() }))
|
||||
.filter((item) => item.id)
|
||||
}
|
||||
|
||||
async function resolveSmartEntryRecognitionTaskItem(task) {
|
||||
const availableItem = task.availableItems.shift()
|
||||
if (availableItem?.id) {
|
||||
return { id: availableItem.id, createdItem: null }
|
||||
}
|
||||
|
||||
const claim = await createExpenseClaimItem(task.claimId, {})
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const createdItem = items.find((entry) => {
|
||||
const itemId = String(entry?.id || '').trim()
|
||||
return itemId && !task.knownItemIds.has(itemId)
|
||||
})
|
||||
|
||||
if (!createdItem) {
|
||||
throw new Error('新增费用明细失败,请稍后重试。')
|
||||
}
|
||||
|
||||
const itemId = String(createdItem.id || '').trim()
|
||||
task.knownItemIds.add(itemId)
|
||||
return { id: itemId, createdItem }
|
||||
}
|
||||
|
||||
async function runSmartEntryRecognitionTask(task, files) {
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
for (let index = 0; index < files.length; index += 1) {
|
||||
const file = files[index]
|
||||
const fileName = String(file?.name || `第 ${index + 1} 张附件`).trim()
|
||||
task.current = index + 1
|
||||
task.fileName = fileName
|
||||
task.uploadingItemId = ''
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
try {
|
||||
const targetItem = await resolveSmartEntryRecognitionTaskItem(task)
|
||||
task.uploadingItemId = targetItem.id
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
|
||||
const payload = await uploadExpenseClaimItemAttachment(task.claimId, targetItem.id, file)
|
||||
task.successCount += 1
|
||||
task.payloads.push({
|
||||
id: `${task.id}:${index}:${targetItem.id}`,
|
||||
itemId: targetItem.id,
|
||||
fileName,
|
||||
payload,
|
||||
createdItem: targetItem.createdItem
|
||||
})
|
||||
} catch (error) {
|
||||
task.failedCount += 1
|
||||
task.errors.push({
|
||||
fileName,
|
||||
message: error?.message || '附件识别失败,请稍后重试。'
|
||||
})
|
||||
} finally {
|
||||
task.completed = index + 1
|
||||
task.uploadingItemId = ''
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
}
|
||||
}
|
||||
|
||||
task.busy = false
|
||||
task.current = task.total
|
||||
task.fileName = ''
|
||||
task.status = task.failedCount
|
||||
? task.successCount
|
||||
? 'partial'
|
||||
: 'failed'
|
||||
: 'completed'
|
||||
notifySmartEntryRecognitionTask(task)
|
||||
scheduleSmartEntryRecognitionTaskCleanup(task)
|
||||
}
|
||||
|
||||
function startSmartEntryRecognitionTask({ claimId, files, itemSnapshots }) {
|
||||
const normalizedClaimId = normalizeSmartEntryClaimId(claimId)
|
||||
const pendingFiles = Array.isArray(files) ? files.filter(Boolean) : []
|
||||
if (!normalizedClaimId || !pendingFiles.length) {
|
||||
return { task: null, reused: false }
|
||||
}
|
||||
|
||||
const existingTask = getSmartEntryRecognitionTask(normalizedClaimId)
|
||||
if (existingTask?.busy) {
|
||||
return { task: existingTask, reused: true }
|
||||
}
|
||||
|
||||
const sourceItems = Array.isArray(itemSnapshots) ? itemSnapshots : []
|
||||
const task = {
|
||||
id: `smart-entry-${Date.now()}-${smartEntryRecognitionTaskSeq += 1}`,
|
||||
claimId: normalizedClaimId,
|
||||
busy: true,
|
||||
total: pendingFiles.length,
|
||||
current: 0,
|
||||
completed: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
uploadingItemId: '',
|
||||
fileName: '',
|
||||
status: 'running',
|
||||
payloads: [],
|
||||
errors: [],
|
||||
availableItems: resolveSmartEntryTaskAvailableItems(sourceItems),
|
||||
knownItemIds: new Set(sourceItems.map((item) => String(item?.id || '').trim()).filter(Boolean)),
|
||||
listeners: new Set(),
|
||||
cleanupTimer: null
|
||||
}
|
||||
|
||||
smartEntryRecognitionTasks.set(normalizedClaimId, task)
|
||||
void runSmartEntryRecognitionTask(task, pendingFiles)
|
||||
return { task, reused: false }
|
||||
}
|
||||
|
||||
/*
|
||||
* 以下片段仅用于兼容现有源码正则测试。
|
||||
* 运行时实现位于 travelRequestDetailExpenseModel.js。
|
||||
@@ -388,6 +605,8 @@ export default {
|
||||
const riskOverrideDialogOpen = ref(false)
|
||||
const riskOverrideBusy = ref(false)
|
||||
const riskOverrideIndex = ref(0)
|
||||
const highlightedRiskCardId = ref('')
|
||||
let highlightedRiskCardTimer = 0
|
||||
const riskOverrideReasons = reactive({})
|
||||
const deleteBusy = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
@@ -397,6 +616,16 @@ export default {
|
||||
const approveConfirmDialogOpen = ref(false)
|
||||
const leaderOpinion = ref('')
|
||||
const expenseUploadInput = ref(null)
|
||||
const smartEntryUploadInput = ref(null)
|
||||
const smartEntryUploadDialogOpen = ref(false)
|
||||
const smartEntrySelectedFiles = ref([])
|
||||
const smartEntryRecognitionBusy = ref(false)
|
||||
const smartEntryRecognitionTotal = ref(0)
|
||||
const smartEntryRecognitionCompleted = ref(0)
|
||||
const smartEntryRecognitionCurrent = ref(0)
|
||||
const appliedSmartEntryRecognitionPayloadIds = new Set()
|
||||
const notifiedSmartEntryRecognitionTaskIds = new Set()
|
||||
let stopSmartEntryRecognitionTask = null
|
||||
const expenseAttachmentMeta = reactive({})
|
||||
const attachmentPreviewOpen = ref(false)
|
||||
const attachmentPreviewLoading = ref(false)
|
||||
@@ -411,6 +640,7 @@ export default {
|
||||
itemReason: '',
|
||||
itemLocation: '',
|
||||
itemAmount: '',
|
||||
itemNote: '',
|
||||
invoiceId: ''
|
||||
})
|
||||
const detailNoteEditor = ref('')
|
||||
@@ -669,6 +899,7 @@ export default {
|
||||
|| approveBusy.value
|
||||
|| payBusy.value
|
||||
|| creatingExpense.value
|
||||
|| smartEntryRecognitionBusy.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
|| Boolean(deletingExpenseId.value)
|
||||
@@ -773,7 +1004,7 @@ export default {
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const expenseTableColumnCount = computed(
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
() => 7 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const canEditDetailNote = computed(() => isDraftRequest.value)
|
||||
const stripDetailNoteRiskTags = (value) =>
|
||||
@@ -821,12 +1052,42 @@ export default {
|
||||
() => request.value.claimId,
|
||||
() => {
|
||||
riskFlagPreviewSnapshot.value = null
|
||||
}
|
||||
appliedSmartEntryRecognitionPayloadIds.clear()
|
||||
bindSmartEntryRecognitionTask()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isEditableRequest.value && !actionBusy.value)
|
||||
const smartEntryRecognitionText = computed(() => {
|
||||
const total = smartEntryRecognitionTotal.value
|
||||
if (!total) {
|
||||
return '附件识别准备中,请稍候。识别完成前暂不可编辑费用明细。'
|
||||
}
|
||||
const current = Math.min(Math.max(smartEntryRecognitionCurrent.value || 1, 1), total)
|
||||
return `附件识别中(${current}/${total}),请稍候。识别完成前暂不可编辑费用明细。`
|
||||
})
|
||||
const smartEntrySelectedFileCount = computed(() => smartEntrySelectedFiles.value.length)
|
||||
const smartEntrySelectedFileNames = computed(() =>
|
||||
smartEntrySelectedFiles.value
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
const smartEntrySelectedFileSummary = computed(() => {
|
||||
const names = smartEntrySelectedFileNames.value
|
||||
if (!names.length) {
|
||||
return ''
|
||||
}
|
||||
if (names.length === 1) {
|
||||
return names[0]
|
||||
}
|
||||
return `已选择 ${names.length} 张附件`
|
||||
})
|
||||
const smartEntryUploadBusy = computed(() =>
|
||||
smartEntryUploadDialogOpen.value && (creatingExpense.value || Boolean(uploadingExpenseId.value))
|
||||
)
|
||||
const attachmentPreviewEntries = computed(() =>
|
||||
expenseItems.value
|
||||
.filter((item) => canPreviewAttachment(item))
|
||||
@@ -929,6 +1190,102 @@ export default {
|
||||
return `${label}:${summary}`
|
||||
}
|
||||
|
||||
function resetSmartEntryRecognitionState() {
|
||||
smartEntryRecognitionBusy.value = false
|
||||
smartEntryRecognitionTotal.value = 0
|
||||
smartEntryRecognitionCompleted.value = 0
|
||||
smartEntryRecognitionCurrent.value = 0
|
||||
if (!pendingUploadExpenseId.value) {
|
||||
uploadingExpenseId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function ensureSmartEntryRecognitionItem(entry, patch) {
|
||||
const itemId = String(entry?.itemId || '').trim()
|
||||
if (!itemId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const existingItem = expenseItems.value.find((item) => item.id === itemId)
|
||||
if (existingItem) {
|
||||
return existingItem
|
||||
}
|
||||
|
||||
const rawItem = entry?.createdItem || {
|
||||
id: itemId,
|
||||
invoice_id: patch.invoiceId,
|
||||
item_date: patch.itemDate,
|
||||
item_type: patch.itemType,
|
||||
item_reason: patch.itemReason,
|
||||
item_location: patch.itemLocation,
|
||||
item_amount: patch.itemAmount,
|
||||
attachment_hint: patch.attachmentHint
|
||||
}
|
||||
const nextItem = buildExpenseItemViewModel(rawItem, expenseItems.value.length, request.value)
|
||||
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
||||
return nextItem
|
||||
}
|
||||
|
||||
function applySmartEntryRecognitionPayload(entry) {
|
||||
const payloadId = String(entry?.id || '').trim()
|
||||
const itemId = String(entry?.itemId || '').trim()
|
||||
if (!payloadId || !itemId || appliedSmartEntryRecognitionPayloadIds.has(payloadId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const itemPatch = buildRecognizedExpenseItemPatch(entry.payload, entry.fileName)
|
||||
const item = ensureSmartEntryRecognitionItem(entry, itemPatch)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
applyClaimRiskFlagsPayload(entry.payload)
|
||||
if (entry.payload?.attachment) {
|
||||
expenseAttachmentMeta[itemId] = entry.payload.attachment
|
||||
}
|
||||
applyLocalExpenseItemPatch(itemId, itemPatch)
|
||||
if (editingExpenseId.value === itemId) {
|
||||
populateExpenseEditor({ ...item, ...itemPatch })
|
||||
}
|
||||
appliedSmartEntryRecognitionPayloadIds.add(payloadId)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
}
|
||||
|
||||
function syncSmartEntryRecognitionSnapshot(snapshot) {
|
||||
if (!snapshot) {
|
||||
resetSmartEntryRecognitionState()
|
||||
return
|
||||
}
|
||||
|
||||
smartEntryRecognitionBusy.value = Boolean(snapshot.busy)
|
||||
smartEntryRecognitionTotal.value = snapshot.total || 0
|
||||
smartEntryRecognitionCompleted.value = snapshot.completed || 0
|
||||
smartEntryRecognitionCurrent.value = snapshot.current || 0
|
||||
uploadingExpenseId.value = snapshot.uploadingItemId || ''
|
||||
|
||||
snapshot.payloads.forEach((entry) => applySmartEntryRecognitionPayload(entry))
|
||||
|
||||
if (!snapshot.busy && snapshot.status && !notifiedSmartEntryRecognitionTaskIds.has(snapshot.id)) {
|
||||
notifiedSmartEntryRecognitionTaskIds.add(snapshot.id)
|
||||
if (snapshot.failedCount && snapshot.successCount) {
|
||||
toast(`已完成 ${snapshot.successCount} 张附件识别,${snapshot.failedCount} 张识别失败。`)
|
||||
} else if (snapshot.failedCount) {
|
||||
toast('附件识别失败,请稍后重试。')
|
||||
} else if (snapshot.total > 1) {
|
||||
toast(`已完成 ${snapshot.successCount} 张附件的智能录入。`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bindSmartEntryRecognitionTask(claimId = request.value.claimId) {
|
||||
if (stopSmartEntryRecognitionTask) {
|
||||
stopSmartEntryRecognitionTask()
|
||||
stopSmartEntryRecognitionTask = null
|
||||
}
|
||||
|
||||
stopSmartEntryRecognitionTask = subscribeSmartEntryRecognitionTask(claimId, syncSmartEntryRecognitionSnapshot)
|
||||
}
|
||||
|
||||
async function refreshExpenseAttachmentMeta(itemId) {
|
||||
if (!request.value.claimId || !itemId) {
|
||||
return null
|
||||
@@ -1048,10 +1405,19 @@ export default {
|
||||
return normalizeRiskTone(resolveExpenseRiskState(item)?.tone) === 'high'
|
||||
}
|
||||
|
||||
function hasExpenseRiskOrAbnormal(item) {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
return Boolean(
|
||||
String(item?.itemNote || '').trim()
|
||||
|| normalizeRiskTone(state?.tone) !== 'low'
|
||||
|| item?.tone === 'bad'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveExpenseRiskIndicatorTitle(item) {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
const summary = String(state?.summary || state?.headline || '').trim()
|
||||
return summary ? `重大风险警示:${summary}` : '重大风险警示'
|
||||
return summary ? `查看风险提示:${summary}` : '查看风险提示'
|
||||
}
|
||||
|
||||
function applyClaimRiskFlagsPayload(payload) {
|
||||
@@ -1198,6 +1564,62 @@ export default {
|
||||
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|
||||
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value)
|
||||
))
|
||||
|
||||
function normalizeRiskDomId(value) {
|
||||
return String(value || '').trim().replace(/[^A-Za-z0-9_-]/g, '-') || 'unknown'
|
||||
}
|
||||
|
||||
function resolveRiskCardDomId(card) {
|
||||
return `detail-risk-card-${normalizeRiskDomId(card?.id)}`
|
||||
}
|
||||
|
||||
function isHighlightedRiskCard(card) {
|
||||
return Boolean(card?.id) && String(card.id) === highlightedRiskCardId.value
|
||||
}
|
||||
|
||||
function resolveExpenseRiskTargetCard(item) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || '').trim()
|
||||
const itemIndex = expenseItems.value.findIndex((entry) => entry.id === item?.id) + 1
|
||||
const cards = Array.isArray(aiAdvice.value?.riskCards) ? aiAdvice.value.riskCards : []
|
||||
const actionableCards = cards.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||||
|
||||
return actionableCards.find((card) => String(card?.itemId || card?.item_id || '').trim() === itemId)
|
||||
|| actionableCards.find((card) => invoiceId && String(card?.invoiceId || card?.invoice_id || '').trim() === invoiceId)
|
||||
|| actionableCards.find((card) => Number(card?.itemIndex || card?.item_index || 0) === itemIndex)
|
||||
|| actionableCards.find((card) => itemIndex > 0 && String(card?.title || '').includes(`第 ${itemIndex} 条`))
|
||||
|| null
|
||||
}
|
||||
|
||||
function hasExpenseRiskIndicator(item) {
|
||||
return Boolean(resolveExpenseRiskTargetCard(item))
|
||||
}
|
||||
|
||||
async function focusExpenseRisk(item) {
|
||||
const card = resolveExpenseRiskTargetCard(item)
|
||||
const riskSection = document.querySelector('.validation-section--risk')
|
||||
if (!card && !riskSection) {
|
||||
toast('当前费用明细暂无可定位的风险点。')
|
||||
return
|
||||
}
|
||||
|
||||
highlightedRiskCardId.value = card?.id ? String(card.id) : ''
|
||||
await nextTick()
|
||||
|
||||
const target = card
|
||||
? document.getElementById(resolveRiskCardDomId(card))
|
||||
: riskSection
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
if (highlightedRiskCardTimer) {
|
||||
window.clearTimeout(highlightedRiskCardTimer)
|
||||
}
|
||||
highlightedRiskCardTimer = window.setTimeout(() => {
|
||||
highlightedRiskCardId.value = ''
|
||||
highlightedRiskCardTimer = 0
|
||||
}, 1800)
|
||||
}
|
||||
|
||||
const aiAdviceTitle = computed(() => {
|
||||
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
|
||||
return '报销风险提示'
|
||||
@@ -1375,6 +1797,7 @@ export default {
|
||||
expenseEditor.itemLocation =
|
||||
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
|
||||
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
|
||||
expenseEditor.itemNote = item.itemNote || ''
|
||||
expenseEditor.invoiceId = item.invoiceId || ''
|
||||
}
|
||||
|
||||
@@ -1416,14 +1839,10 @@ export default {
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleAddExpenseItem() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
async function createDraftExpenseItem({ openEditor = true } = {}) {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法新增费用明细。')
|
||||
return
|
||||
return null
|
||||
}
|
||||
|
||||
creatingExpense.value = true
|
||||
@@ -1441,15 +1860,108 @@ export default {
|
||||
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
|
||||
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
|
||||
creatingExpense.value = false
|
||||
startExpenseEdit(nextItem)
|
||||
toast('已新增一条费用明细,请继续填写。')
|
||||
if (openEditor) {
|
||||
startExpenseEdit(nextItem)
|
||||
toast('已新增一条费用明细,请继续填写。')
|
||||
}
|
||||
return nextItem
|
||||
} catch (error) {
|
||||
toast(error?.message || '新增费用明细失败,请稍后重试。')
|
||||
return null
|
||||
} finally {
|
||||
creatingExpense.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddExpenseItem() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
await createDraftExpenseItem({ openEditor: true })
|
||||
}
|
||||
|
||||
function triggerSmartEntryUpload() {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
||||
return
|
||||
}
|
||||
|
||||
smartEntrySelectedFiles.value = []
|
||||
smartEntryUploadDialogOpen.value = true
|
||||
}
|
||||
|
||||
function closeSmartEntryUploadDialog() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
smartEntryUploadDialogOpen.value = false
|
||||
clearSmartEntryFile()
|
||||
}
|
||||
|
||||
function chooseSmartEntryFile() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
if (smartEntryUploadInput.value) {
|
||||
smartEntryUploadInput.value.value = ''
|
||||
smartEntryUploadInput.value.click()
|
||||
}
|
||||
}
|
||||
|
||||
function clearSmartEntryFile() {
|
||||
smartEntrySelectedFiles.value = []
|
||||
if (smartEntryUploadInput.value) {
|
||||
smartEntryUploadInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function handleSmartEntryFileChange(event) {
|
||||
const target = event?.target
|
||||
const fileList = target?.files
|
||||
const files = Array.from(fileList || [])
|
||||
|
||||
if (target) {
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
if (!files.length) {
|
||||
return
|
||||
}
|
||||
|
||||
smartEntrySelectedFiles.value = files
|
||||
}
|
||||
|
||||
async function confirmSmartEntryUpload() {
|
||||
if (smartEntryUploadBusy.value) {
|
||||
return
|
||||
}
|
||||
const files = [...smartEntrySelectedFiles.value]
|
||||
if (!files.length) {
|
||||
toast('请先选择需要智能录入的附件。')
|
||||
return
|
||||
}
|
||||
|
||||
smartEntryUploadDialogOpen.value = false
|
||||
clearSmartEntryFile()
|
||||
const { task, reused } = startSmartEntryRecognitionTask({
|
||||
claimId: request.value.claimId,
|
||||
files,
|
||||
itemSnapshots: expenseItems.value
|
||||
})
|
||||
if (!task) {
|
||||
toast('当前草稿缺少 claimId,暂时无法识别附件。')
|
||||
return
|
||||
}
|
||||
|
||||
bindSmartEntryRecognitionTask(request.value.claimId)
|
||||
toast(reused ? '当前单据已有附件识别任务,请等待识别完成。' : '附件已转入后台识别,费用明细将在识别完成后自动更新。')
|
||||
}
|
||||
|
||||
function triggerExpenseUpload(item) {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
@@ -1570,31 +2082,7 @@ export default {
|
||||
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
|
||||
applyClaimRiskFlagsPayload(payload)
|
||||
expenseAttachmentMeta[item.id] = payload?.attachment || null
|
||||
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
|
||||
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
|
||||
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
|
||||
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
|
||||
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
|
||||
const itemPatch = {
|
||||
invoiceId: String(payload?.invoice_id || '').trim(),
|
||||
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
|
||||
}
|
||||
if (recognizedItemDate) {
|
||||
itemPatch.itemDate = recognizedItemDate
|
||||
}
|
||||
if (recognizedItemType) {
|
||||
itemPatch.itemType = recognizedItemType
|
||||
}
|
||||
if (recognizedItemReason) {
|
||||
itemPatch.itemReason = recognizedItemReason
|
||||
}
|
||||
if (recognizedItemLocation) {
|
||||
itemPatch.itemLocation = recognizedItemLocation
|
||||
}
|
||||
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
|
||||
itemPatch.itemAmount = recognizedItemAmount
|
||||
itemPatch.amount = formatCurrency(recognizedItemAmount)
|
||||
}
|
||||
const itemPatch = buildRecognizedExpenseItemPatch(payload, file.name)
|
||||
applyLocalExpenseItemPatch(item.id, {
|
||||
...itemPatch
|
||||
})
|
||||
@@ -1603,8 +2091,10 @@ export default {
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
|
||||
toast(riskNotice || payload?.message || `${file.name} 已关联到当前费用明细。`)
|
||||
return true
|
||||
} catch (error) {
|
||||
toast(error?.message || '附件上传失败,请稍后重试。')
|
||||
return false
|
||||
} finally {
|
||||
uploadingExpenseId.value = ''
|
||||
}
|
||||
@@ -1693,6 +2183,7 @@ export default {
|
||||
expenseEditor.itemReason = ''
|
||||
expenseEditor.itemLocation = ''
|
||||
expenseEditor.itemAmount = ''
|
||||
expenseEditor.itemNote = ''
|
||||
expenseEditor.invoiceId = ''
|
||||
}
|
||||
if (pendingUploadExpenseId.value === item.id) {
|
||||
@@ -1736,6 +2227,7 @@ export default {
|
||||
item_type: expenseEditor.itemType,
|
||||
item_reason: expenseEditor.itemReason.trim(),
|
||||
item_location: preservedLocation,
|
||||
item_note: expenseEditor.itemNote.trim(),
|
||||
item_amount: nextAmount,
|
||||
invoice_id: nextInvoiceId
|
||||
}
|
||||
@@ -1748,6 +2240,7 @@ export default {
|
||||
itemType: expenseEditor.itemType,
|
||||
itemReason: expenseEditor.itemReason.trim(),
|
||||
itemLocation: preservedLocation,
|
||||
itemNote: expenseEditor.itemNote.trim(),
|
||||
itemAmount: nextAmount,
|
||||
invoiceId: nextInvoiceId
|
||||
})
|
||||
@@ -1788,11 +2281,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitConfirmDialogOpen.value = true
|
||||
}
|
||||
|
||||
@@ -1823,12 +2311,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
}
|
||||
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await submitExpenseClaim(request.value.claimId)
|
||||
@@ -2007,26 +2489,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function openAiEntry() {
|
||||
if (!canOpenAiEntry.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const claimId = String(request.value?.claimId || '').trim()
|
||||
emit('openAssistant', {
|
||||
source: 'detail',
|
||||
prompt: '',
|
||||
request: request.value,
|
||||
restoreLatestConversation: false,
|
||||
scope: claimId
|
||||
? {
|
||||
type: 'claim',
|
||||
claimId
|
||||
}
|
||||
: null
|
||||
})
|
||||
}
|
||||
|
||||
function buildApplicationEditPreview() {
|
||||
const factEntries = applicationDetailFactItems.value
|
||||
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
|
||||
@@ -2098,6 +2560,14 @@ export default {
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (highlightedRiskCardTimer) {
|
||||
window.clearTimeout(highlightedRiskCardTimer)
|
||||
highlightedRiskCardTimer = 0
|
||||
}
|
||||
if (stopSmartEntryRecognitionTask) {
|
||||
stopSmartEntryRecognitionTask()
|
||||
stopSmartEntryRecognitionTask = null
|
||||
}
|
||||
closeAttachmentPreview()
|
||||
})
|
||||
|
||||
@@ -2112,9 +2582,10 @@ export default {
|
||||
canNavigateAttachmentPreview,
|
||||
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
|
||||
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
|
||||
closeRiskOverrideDialog,
|
||||
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
|
||||
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
|
||||
confirmPayRequest, confirmRiskOverrideReasons,
|
||||
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload,
|
||||
chooseSmartEntryFile, clearSmartEntryFile,
|
||||
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
|
||||
currentSubmitRiskWarning,
|
||||
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
|
||||
@@ -2123,20 +2594,27 @@ export default {
|
||||
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
goToNextSubmitRisk, goToPreviousSubmitRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
|
||||
focusExpenseRisk,
|
||||
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
|
||||
handleModifyApplication,
|
||||
handlePayRequest,
|
||||
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
hasExpenseRiskIndicator,
|
||||
hasExpenseRiskOrAbnormal,
|
||||
triggerSmartEntryUpload, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
||||
resolveExpenseRiskIndicatorTitle,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
resolveRiskCardDomId, isHighlightedRiskCard,
|
||||
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
|
||||
requiresApprovalOpinion,
|
||||
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
|
||||
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
|
||||
smartEntryRecognitionBusy, smartEntryRecognitionText,
|
||||
smartEntryUploadBusy, smartEntryUploadDialogOpen, smartEntryUploadInput,
|
||||
showAiAdvicePanel, showApplicationLeaderOpinion,
|
||||
showBudgetAnalysis, showStageRiskAdvice,
|
||||
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
|
||||
|
||||
Reference in New Issue
Block a user