feat: 完善报销单审批流程及退回原因追踪
新增直属领导审批通过接口和审批待办列表查询,报销单退回 支持原因码分类和审批环节标记,优化票据附件去重和路径 回退查找,前端新增退回原因对话框、审批收件箱和工作台 图标组件,补充工具函数和单元测试覆盖。
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
|
||||
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
|
||||
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
|
||||
import { mapExpenseClaimToRequest } from '../../composables/useRequests.js'
|
||||
import { useApprovalInbox } from '../../composables/useApprovalInbox.js'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
import { useToast } from '../../composables/useToast.js'
|
||||
import { deleteExpenseClaim, fetchExpenseClaims, returnExpenseClaim } from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { fetchApprovalExpenseClaims } from '../../services/reimbursements.js'
|
||||
import { listPendingApprovalRequests } from '../../utils/approvalInbox.js'
|
||||
import TravelRequestDetailView from '../TravelRequestDetailView.vue'
|
||||
|
||||
const DEFAULT_SLA_HOURS = 24
|
||||
const tabs = ['全部待审', '高风险', '即将超时', '已处理']
|
||||
@@ -61,94 +60,6 @@ function resolveRiskTone(riskFlags, riskSummary) {
|
||||
return 'low'
|
||||
}
|
||||
|
||||
function resolveRiskItems(request) {
|
||||
const riskFlags = Array.isArray(request?.riskFlags) ? request.riskFlags : []
|
||||
const items = riskFlags
|
||||
.map((item) => {
|
||||
const tone = resolveRiskTone([item], '')
|
||||
const text = String(item?.message || item?.label || item?.reason || '').trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
text,
|
||||
level: tone === 'high' ? '高' : tone === 'medium' ? '中' : '低',
|
||||
tone,
|
||||
icon: tone === 'high' ? 'mdi mdi-alert-circle' : tone === 'medium' ? 'mdi mdi-alert' : 'mdi mdi-shield-check'
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (items.length) {
|
||||
return items
|
||||
}
|
||||
|
||||
const summary = String(request?.riskSummary || '').trim()
|
||||
if (summary && summary !== '无') {
|
||||
return summary.split(';').filter(Boolean).map((text) => ({
|
||||
text,
|
||||
level: '中',
|
||||
tone: 'medium',
|
||||
icon: 'mdi mdi-alert'
|
||||
}))
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: 'AI预审已通过,当前未发现额外风险。',
|
||||
level: '低',
|
||||
tone: 'low',
|
||||
icon: 'mdi mdi-shield-check'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveAttachmentMeta(name) {
|
||||
const normalized = String(name || '').trim()
|
||||
const lowerName = normalized.toLowerCase()
|
||||
if (lowerName.endsWith('.pdf')) {
|
||||
return { icon: 'mdi mdi-file-pdf-box', iconClass: 'pdf' }
|
||||
}
|
||||
if (/\.(png|jpg|jpeg|webp|bmp)$/i.test(lowerName)) {
|
||||
return { icon: 'mdi mdi-image', iconClass: 'img' }
|
||||
}
|
||||
return { icon: 'mdi mdi-file-document-outline', iconClass: 'file' }
|
||||
}
|
||||
|
||||
function buildAttachments(expenseItems) {
|
||||
const seen = new Set()
|
||||
const attachments = []
|
||||
|
||||
for (const item of Array.isArray(expenseItems) ? expenseItems : []) {
|
||||
for (const fileName of Array.isArray(item?.attachments) ? item.attachments : []) {
|
||||
const normalized = String(fileName || '').trim()
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue
|
||||
}
|
||||
seen.add(normalized)
|
||||
attachments.push({
|
||||
name: normalized,
|
||||
size: '已识别',
|
||||
...resolveAttachmentMeta(normalized)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (attachments.length) {
|
||||
return attachments
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: '当前无附件',
|
||||
size: '待补充',
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
iconClass: 'miss',
|
||||
missing: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function resolveSlaMeta(submittedAt) {
|
||||
const startAt = toDate(submittedAt)
|
||||
if (!startAt) {
|
||||
@@ -173,55 +84,8 @@ function resolveSlaMeta(submittedAt) {
|
||||
return { label, tone: 'safe', urgent: false }
|
||||
}
|
||||
|
||||
function buildHeroSummaryItems(request) {
|
||||
return [
|
||||
{ label: '单号', value: request.id || '-', icon: 'mdi mdi-pound-box-outline' },
|
||||
{ label: '报销类型', value: request.typeLabel || '-', icon: 'mdi mdi-briefcase-outline' },
|
||||
{ label: '业务地点', value: request.sceneTarget || '待补充', icon: 'mdi mdi-map-marker-outline' },
|
||||
{ label: '发生时间', value: request.occurredDisplay || '待补充', icon: 'mdi mdi-calendar-range' },
|
||||
{ label: '票据关联', value: request.attachmentSummary || '无', icon: 'mdi mdi-paperclip' },
|
||||
{ label: '事由', value: request.title || '待补充', icon: 'mdi mdi-text-box-outline' }
|
||||
]
|
||||
}
|
||||
|
||||
function buildFlowItems(request) {
|
||||
return Array.isArray(request?.progressSteps)
|
||||
? request.progressSteps.map((item) => ({
|
||||
label: item.label,
|
||||
desc: item.current ? '当前处理节点' : item.done ? '已完成' : '待处理',
|
||||
time: item.time,
|
||||
icon: item.current ? 'mdi mdi-circle-slice-8' : item.done ? 'mdi mdi-check' : 'mdi mdi-circle-outline',
|
||||
current: item.current,
|
||||
pending: !item.done && !item.current
|
||||
}))
|
||||
: []
|
||||
}
|
||||
|
||||
function canCurrentUserProcessRequest(request, currentUser) {
|
||||
const node = String(request?.workflowNode || '').trim()
|
||||
const currentName = String(currentUser?.name || '').trim()
|
||||
const applicantName = String(request?.person || request?.employeeName || '').trim()
|
||||
|
||||
if (currentName && applicantName && currentName === applicantName) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (canManageExpenseClaims(currentUser)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
node.includes('直属领导')
|
||||
|| node.includes('领导审批')
|
||||
|| node.includes('部门负责人')
|
||||
|| node.includes('负责人审批')
|
||||
)
|
||||
}
|
||||
|
||||
function buildApprovalRow(request) {
|
||||
const riskTone = resolveRiskTone(request.riskFlags, request.riskSummary)
|
||||
const riskItems = resolveRiskItems(request)
|
||||
const expenseItems = Array.isArray(request.expenseItems) ? request.expenseItems : []
|
||||
const slaMeta = resolveSlaMeta(request.submittedAt || request.createdAt)
|
||||
const statusTone = slaMeta.urgent ? 'urgent' : 'pending'
|
||||
|
||||
@@ -240,37 +104,35 @@ function buildApprovalRow(request) {
|
||||
node: request.workflowNode || '审批中',
|
||||
status: statusTone === 'urgent' ? '即将超时' : '待审批',
|
||||
statusTone,
|
||||
spotlight: riskTone === 'high' || statusTone === 'urgent',
|
||||
heroSummaryItems: buildHeroSummaryItems(request),
|
||||
summaryItems: buildHeroSummaryItems(request).slice(2),
|
||||
progressSteps: Array.isArray(request.progressSteps) ? request.progressSteps : [],
|
||||
expenseItems,
|
||||
attachments: buildAttachments(expenseItems),
|
||||
riskItems,
|
||||
flowItems: buildFlowItems(request)
|
||||
spotlight: riskTone === 'high' || statusTone === 'urgent'
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'ApprovalCenterView',
|
||||
components: {
|
||||
ConfirmDialog,
|
||||
TravelRequestDetailView,
|
||||
TableLoadingState,
|
||||
TableEmptyState
|
||||
},
|
||||
setup() {
|
||||
const { currentUser } = useSystemState()
|
||||
const { toast } = useToast()
|
||||
const { markClaimViewed, syncPendingClaimIds } = useApprovalInbox()
|
||||
const activeTab = ref('全部待审')
|
||||
const selectedClaimId = ref('')
|
||||
const expandedExpenseId = ref(null)
|
||||
const listKeyword = ref('')
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const actionBusy = ref(false)
|
||||
const returnDialogOpen = ref(false)
|
||||
const deleteDialogOpen = ref(false)
|
||||
|
||||
watch(
|
||||
() => selectedClaimId.value,
|
||||
(claimId) => {
|
||||
if (claimId) {
|
||||
markClaimViewed(claimId)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const selectedRow = computed({
|
||||
get() {
|
||||
@@ -278,14 +140,12 @@ export default {
|
||||
},
|
||||
set(value) {
|
||||
selectedClaimId.value = value?.claimId || ''
|
||||
expandedExpenseId.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const visibleRows = computed(() => {
|
||||
let filteredRows = rows.value
|
||||
|
||||
// 根据标签筛选
|
||||
if (activeTab.value === '高风险') {
|
||||
filteredRows = filteredRows.filter((row) => row.riskTone === 'high')
|
||||
} else if (activeTab.value === '即将超时') {
|
||||
@@ -294,25 +154,20 @@ export default {
|
||||
filteredRows = []
|
||||
}
|
||||
|
||||
// 根据搜索关键词筛选
|
||||
if (listKeyword.value.trim()) {
|
||||
const keyword = listKeyword.value.trim().toLowerCase()
|
||||
filteredRows = filteredRows.filter((row) => {
|
||||
return (
|
||||
String(row.id || '').toLowerCase().includes(keyword) ||
|
||||
String(row.applicant || '').toLowerCase().includes(keyword) ||
|
||||
String(row.department || '').toLowerCase().includes(keyword) ||
|
||||
String(row.type || '').toLowerCase().includes(keyword) ||
|
||||
String(row.amount || '').toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
filteredRows = filteredRows.filter((row) => (
|
||||
String(row.id || '').toLowerCase().includes(keyword)
|
||||
|| String(row.applicant || '').toLowerCase().includes(keyword)
|
||||
|| String(row.department || '').toLowerCase().includes(keyword)
|
||||
|| String(row.type || '').toLowerCase().includes(keyword)
|
||||
|| String(row.amount || '').toLowerCase().includes(keyword)
|
||||
))
|
||||
}
|
||||
|
||||
return filteredRows
|
||||
})
|
||||
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
|
||||
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
|
||||
const canManageClaims = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const approvalEmptyState = computed(() => {
|
||||
if (!rows.value.length) {
|
||||
return {
|
||||
@@ -343,45 +198,6 @@ export default {
|
||||
}
|
||||
})
|
||||
|
||||
const approvalSteps = computed(() => selectedRow.value?.progressSteps || [])
|
||||
const summaryItems = computed(() => selectedRow.value?.summaryItems || [])
|
||||
const heroSummaryItems = computed(() => selectedRow.value?.heroSummaryItems || [])
|
||||
const expenseItems = computed(() => selectedRow.value?.expenseItems || [])
|
||||
const expenseTotal = computed(() => selectedRow.value?.amount || formatCurrency(0))
|
||||
const uploadedExpenseCount = computed(
|
||||
() => expenseItems.value.filter((item) => Array.isArray(item?.attachments) && item.attachments.length).length
|
||||
)
|
||||
const attachments = computed(() => selectedRow.value?.attachments || [])
|
||||
const riskItems = computed(() => selectedRow.value?.riskItems || [])
|
||||
const flowItems = computed(() => selectedRow.value?.flowItems || [])
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showExpenseRisk(item) {
|
||||
return ['medium', 'high'].includes(String(item?.riskTone || '').trim())
|
||||
}
|
||||
|
||||
function toggleExpenseAttachments(id) {
|
||||
expandedExpenseId.value = expandedExpenseId.value === id ? null : id
|
||||
}
|
||||
|
||||
function handleEmptyAction() {
|
||||
if (!rows.value.length) {
|
||||
void reload()
|
||||
@@ -391,74 +207,18 @@ export default {
|
||||
activeTab.value = '全部待审'
|
||||
}
|
||||
|
||||
function handleReturnSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
returnDialogOpen.value = true
|
||||
function closeSelectedDetail() {
|
||||
selectedClaimId.value = ''
|
||||
}
|
||||
|
||||
function handleDeleteSelected() {
|
||||
if (!selectedRow.value?.claimId || !canManageClaims.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteDialogOpen.value = true
|
||||
async function handleDetailUpdated() {
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
}
|
||||
|
||||
function closeReturnDialog() {
|
||||
if (!actionBusy.value) {
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeDeleteDialog() {
|
||||
if (!actionBusy.value) {
|
||||
deleteDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReturnSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(row.claimId, {
|
||||
reason: '审批中心退回,请申请人调整后重新提交。'
|
||||
})
|
||||
toast(`${row.id} 已退回待提交。`)
|
||||
returnDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '退回单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDeleteSelected() {
|
||||
const row = selectedRow.value
|
||||
if (!row?.claimId || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
|
||||
actionBusy.value = true
|
||||
try {
|
||||
const payload = await deleteExpenseClaim(row.claimId)
|
||||
toast(payload?.message || `${row.id} 报销单已删除。`)
|
||||
deleteDialogOpen.value = false
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
} catch (nextError) {
|
||||
toast(nextError?.message || '删除单据失败,请稍后重试。')
|
||||
} finally {
|
||||
actionBusy.value = false
|
||||
}
|
||||
async function handleDetailDeleted() {
|
||||
selectedClaimId.value = ''
|
||||
await reload()
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
@@ -466,15 +226,11 @@ export default {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = await fetchExpenseClaims()
|
||||
const mappedRows = Array.isArray(payload)
|
||||
? payload
|
||||
.map((item) => mapExpenseClaimToRequest(item))
|
||||
.filter((item) => item.approvalKey === 'in_progress')
|
||||
.filter((item) => canCurrentUserProcessRequest(item, currentUser.value))
|
||||
.map((item) => buildApprovalRow(item))
|
||||
: []
|
||||
const payload = await fetchApprovalExpenseClaims()
|
||||
const pendingRequests = listPendingApprovalRequests(payload, currentUser.value)
|
||||
const mappedRows = pendingRequests.map((item) => buildApprovalRow(item))
|
||||
rows.value = mappedRows
|
||||
syncPendingClaimIds(mappedRows.map((item) => item.claimId))
|
||||
if (!mappedRows.some((item) => item.claimId === selectedClaimId.value)) {
|
||||
selectedClaimId.value = ''
|
||||
}
|
||||
@@ -491,42 +247,21 @@ export default {
|
||||
|
||||
return {
|
||||
activeTab,
|
||||
selectedRow,
|
||||
expandedExpenseId,
|
||||
listKeyword,
|
||||
tabs,
|
||||
filters,
|
||||
rows,
|
||||
visibleRows,
|
||||
showTable,
|
||||
showEmpty,
|
||||
actionBusy,
|
||||
approvalEmptyState,
|
||||
approvalSteps,
|
||||
canManageClaims,
|
||||
closeDeleteDialog,
|
||||
closeReturnDialog,
|
||||
confirmDeleteSelected,
|
||||
confirmReturnSelected,
|
||||
deleteDialogOpen,
|
||||
summaryItems,
|
||||
heroSummaryItems,
|
||||
currentProgressRingMotion,
|
||||
expenseItems,
|
||||
expenseTotal,
|
||||
uploadedExpenseCount,
|
||||
showExpenseRisk,
|
||||
toggleExpenseAttachments,
|
||||
attachments,
|
||||
riskItems,
|
||||
flowItems,
|
||||
handleEmptyAction,
|
||||
handleDeleteSelected,
|
||||
handleReturnSelected,
|
||||
loading,
|
||||
closeSelectedDetail,
|
||||
error,
|
||||
returnDialogOpen,
|
||||
reload
|
||||
filters,
|
||||
handleDetailDeleted,
|
||||
handleDetailUpdated,
|
||||
handleEmptyAction,
|
||||
listKeyword,
|
||||
loading,
|
||||
reload,
|
||||
rows,
|
||||
selectedRow,
|
||||
showEmpty,
|
||||
tabs,
|
||||
visibleRows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,10 @@ function sameValues(left, right) {
|
||||
return left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
function padDatePart(value) {
|
||||
return String(Number(value)).padStart(2, '0')
|
||||
}
|
||||
|
||||
function formatEmployeeHistoryTime(value) {
|
||||
const raw = normalizeText(value)
|
||||
if (!raw) {
|
||||
@@ -269,13 +273,13 @@ function formatEmployeeHistoryTime(value) {
|
||||
)
|
||||
if (chineseMatched) {
|
||||
const [, year, month, day, hour, minute] = chineseMatched
|
||||
return `${year}年${Number(month)}月${Number(day)}日${Number(hour)}时${Number(minute)}分`
|
||||
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
|
||||
}
|
||||
|
||||
const isoMatched = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?/)
|
||||
const isoMatched = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2}))?/)
|
||||
if (isoMatched) {
|
||||
const [, year, month, day, hour = '0', minute = '0'] = isoMatched
|
||||
return `${year}年${Number(month)}月${Number(day)}日${Number(hour)}时${Number(minute)}分`
|
||||
return `${year}-${padDatePart(month)}-${padDatePart(day)} ${padDatePart(hour)}:${padDatePart(minute)}`
|
||||
}
|
||||
|
||||
return raw.replace(/(\d{1,2}分)\d{1,2}秒$/, '$1')
|
||||
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
emits: ['ask', 'approve', 'reject', 'create-request', 'reload'],
|
||||
setup(props, { emit }) {
|
||||
const activeTab = ref('全部')
|
||||
const tabs = ['全部', '草稿', '审批中', '待补充', '已完成']
|
||||
const tabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
|
||||
const filters = ['报销状态', '报销类型', '所属主体']
|
||||
const listKeyword = ref('')
|
||||
|
||||
@@ -98,8 +98,9 @@ export default {
|
||||
const matchesTab =
|
||||
activeTab.value === '全部'
|
||||
|| (activeTab.value === '草稿' && row.approvalKey === 'draft')
|
||||
|| (activeTab.value === '待提交' && row.approvalKey === 'supplement' && row.status === 'returned')
|
||||
|| (activeTab.value === '审批中' && row.approvalKey === 'in_progress')
|
||||
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement')
|
||||
|| (activeTab.value === '待补充' && row.approvalKey === 'supplement' && row.status !== 'returned')
|
||||
|| (activeTab.value === '已完成' && row.approvalKey === 'completed')
|
||||
|
||||
return matchesKeyword && matchesDateRange && matchesTab
|
||||
@@ -150,7 +151,7 @@ export default {
|
||||
artLabel: hasListFilters.value ? 'FILTER' : 'QUEUE',
|
||||
tips: hasListFilters.value
|
||||
? ['关键词、时间段和状态会叠加生效', '可尝试搜索单号、事由或报销类型']
|
||||
: ['已完成单据会保留在列表中便于追踪', '草稿、审批中和待补充会按真实状态实时归类']
|
||||
: ['已完成单据会保留在列表中便于追踪', '草稿、待提交、审批中和待补充会按真实状态实时归类']
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ import { recognizeOcrFiles } from '../../services/ocr.js'
|
||||
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
|
||||
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
|
||||
import { renderMarkdown } from '../../utils/markdown.js'
|
||||
import {
|
||||
buildLocalExtractionProgressMessages,
|
||||
buildLocalIntentPreview,
|
||||
summarizeSemanticIntentDetail,
|
||||
TRANSPORT_KEYWORD_PATTERN
|
||||
} from '../../utils/reimbursementTextInference.js'
|
||||
import {
|
||||
fetchExpenseClaimAttachmentAsset,
|
||||
fetchExpenseClaimDetail,
|
||||
@@ -284,7 +290,7 @@ const HOT_KNOWLEDGE_QUESTIONS = [
|
||||
const CATEGORY_CONFIDENCE_KEYWORDS = {
|
||||
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
|
||||
hotel: [/住宿|酒店|宾馆|民宿/],
|
||||
transport: [/交通|打车|网约车|出租车|车费|地铁|公交|停车|过路费/],
|
||||
transport: [TRANSPORT_KEYWORD_PATTERN],
|
||||
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
||||
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
||||
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
||||
@@ -304,13 +310,6 @@ const FLOW_MISSING_SLOT_LABELS = {
|
||||
participants: '参与人员',
|
||||
attachments: '票据附件'
|
||||
}
|
||||
const FLOW_INTENT_KEYWORDS = {
|
||||
draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'],
|
||||
query: ['查询', '查一下', '多少', '明细', '统计'],
|
||||
risk_check: ['风险', '异常', '重复', '超标'],
|
||||
explain: ['为什么', '依据', '规则', '怎么']
|
||||
}
|
||||
|
||||
let messageSeed = 0
|
||||
|
||||
function nowTime() {
|
||||
@@ -439,116 +438,6 @@ function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
|
||||
return FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
}
|
||||
|
||||
function summarizeSemanticIntentDetail(semanticParse) {
|
||||
if (!semanticParse || typeof semanticParse !== 'object') {
|
||||
return FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}
|
||||
|
||||
const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
|
||||
const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
|
||||
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}`
|
||||
}
|
||||
|
||||
function extractLocalFlowCandidates(rawText) {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = text.replace(/\s+/g, '')
|
||||
|
||||
let time = ''
|
||||
const explicitTimeMatch = text.match(/发生时间[::]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
|
||||
if (explicitTimeMatch?.[1]) {
|
||||
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
|
||||
} else {
|
||||
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
|
||||
if (dateMatch?.[1]) {
|
||||
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
|
||||
} else if (/今天|今日/.test(compact)) {
|
||||
time = '今天'
|
||||
} else if (/昨天|昨日/.test(compact)) {
|
||||
time = '昨天'
|
||||
} else if (/前天/.test(compact)) {
|
||||
time = '前天'
|
||||
}
|
||||
}
|
||||
|
||||
let amount = ''
|
||||
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
|
||||
if (amountMatch?.[1]) {
|
||||
const numericValue = Number(amountMatch[1])
|
||||
if (Number.isFinite(numericValue)) {
|
||||
amount = Number.isInteger(numericValue) ? `${numericValue}元` : `${numericValue.toFixed(2)}元`
|
||||
}
|
||||
}
|
||||
|
||||
let event = ''
|
||||
let expenseType = ''
|
||||
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
|
||||
event = '请客户吃饭'
|
||||
expenseType = '业务招待费'
|
||||
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
|
||||
event = '出差行程'
|
||||
expenseType = '差旅费'
|
||||
} else if (/打车|网约车|出租车|车费|停车/.test(compact)) {
|
||||
event = '交通出行'
|
||||
expenseType = '交通费'
|
||||
} else if (/住宿|酒店|宾馆/.test(compact)) {
|
||||
event = '住宿报销'
|
||||
expenseType = '住宿费'
|
||||
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
|
||||
event = '餐饮用餐'
|
||||
expenseType = '餐费'
|
||||
}
|
||||
|
||||
return {
|
||||
time,
|
||||
amount,
|
||||
event,
|
||||
expenseType
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) {
|
||||
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
|
||||
return '初步识别为财务知识问答,正在准备检索范围'
|
||||
}
|
||||
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = text.replace(/\s+/g, '')
|
||||
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
|
||||
keywords.some((keyword) => compact.includes(keyword))
|
||||
)?.[0] || 'draft'
|
||||
const intentLabel = INTENT_LABELS[intentKey] || '处理'
|
||||
return `初步识别为报销场景,准备进入${intentLabel}`
|
||||
}
|
||||
|
||||
function buildLocalExtractionProgressMessages(rawText, options = {}) {
|
||||
const candidates = extractLocalFlowCandidates(rawText)
|
||||
const messages = []
|
||||
|
||||
messages.push('正在提取发生时间...')
|
||||
messages.push(
|
||||
candidates.time
|
||||
? `发现发生时间 ${candidates.time},继续提取金额...`
|
||||
: '暂未定位到明确时间,继续提取金额...'
|
||||
)
|
||||
messages.push(
|
||||
candidates.amount
|
||||
? `发现金额 ${candidates.amount},继续识别事件类型...`
|
||||
: '暂未定位到明确金额,继续识别事件类型...'
|
||||
)
|
||||
|
||||
if (candidates.event || candidates.expenseType) {
|
||||
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
|
||||
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
|
||||
} else {
|
||||
messages.push('正在识别事件类型和费用分类...')
|
||||
}
|
||||
|
||||
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
|
||||
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
function formatFlowDuration(ms) {
|
||||
const numericValue = Number(ms)
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
@@ -2039,7 +1928,7 @@ function matchPresetSceneFromReason(reason) {
|
||||
if (/酒店|住宿/.test(compactReason)) {
|
||||
return '住宿报销'
|
||||
}
|
||||
if (/交通|打车|车费|停车|网约车|出租车|地铁|公交/.test(compactReason)) {
|
||||
if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) {
|
||||
return '交通出行'
|
||||
}
|
||||
if (/会务|会议|参会|论坛|展会/.test(compactReason)) {
|
||||
@@ -3162,6 +3051,7 @@ export default {
|
||||
const reviewRecognitionNotes = computed(() => buildReviewRecognitionNotes(activeReviewPayload.value))
|
||||
const reviewDocumentSummaries = computed(() => buildReviewDocumentSummaries(activeReviewPayload.value))
|
||||
const reviewDocumentCount = computed(() => reviewDocumentDrafts.value.length)
|
||||
const isReviewOverviewDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_REVIEW)
|
||||
const isReviewDocumentDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS)
|
||||
const isReviewRiskDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK)
|
||||
const isReviewFlowDrawer = computed(() => reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW)
|
||||
@@ -3175,7 +3065,7 @@ export default {
|
||||
: '报销识别核对'
|
||||
))
|
||||
const reviewDocumentDrawerLabel = computed(() => (
|
||||
isReviewDocumentDrawer.value ? '显示核对' : '显示票据'
|
||||
'单据识别'
|
||||
))
|
||||
const reviewDocumentDrawerIcon = computed(() => (
|
||||
isReviewDocumentDrawer.value
|
||||
@@ -3183,7 +3073,7 @@ export default {
|
||||
: 'mdi mdi-file-document-multiple-outline'
|
||||
))
|
||||
const reviewRiskDrawerLabel = computed(() => (
|
||||
isReviewRiskDrawer.value ? '显示核对' : '显示风险'
|
||||
'显示风险'
|
||||
))
|
||||
const reviewRiskDrawerIcon = computed(() => (
|
||||
isReviewRiskDrawer.value
|
||||
@@ -3191,7 +3081,7 @@ export default {
|
||||
: 'mdi mdi-shield-alert-outline'
|
||||
))
|
||||
const reviewFlowDrawerLabel = computed(() => (
|
||||
isReviewFlowDrawer.value ? '显示核对' : '显示流程'
|
||||
'调用流程'
|
||||
))
|
||||
const reviewFlowDrawerIcon = computed(() => (
|
||||
isReviewFlowDrawer.value
|
||||
@@ -3714,7 +3604,7 @@ export default {
|
||||
|
||||
function startSemanticFlowPreview(rawText, options = {}) {
|
||||
clearFlowSimulationTimers()
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value)
|
||||
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value, { intentLabels: INTENT_LABELS })
|
||||
const extractionMessages = buildLocalExtractionProgressMessages(rawText, options)
|
||||
|
||||
const completeIntentTimer = window.setTimeout(() => {
|
||||
@@ -3867,7 +3757,12 @@ export default {
|
||||
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
completePendingFlowStep(
|
||||
'intent',
|
||||
summarizeSemanticIntentDetail(run.semantic_parse),
|
||||
summarizeSemanticIntentDetail(run.semantic_parse, {
|
||||
scenarioLabels: SCENARIO_LABELS,
|
||||
intentLabels: INTENT_LABELS,
|
||||
expenseTypeLabels: EXPENSE_TYPE_LABELS,
|
||||
fallbackText: FLOW_STEP_FALLBACKS.intent.completedText
|
||||
}),
|
||||
intentStep?.startedAt ? null : semanticDurations.intentMs
|
||||
)
|
||||
completePendingFlowStep(
|
||||
@@ -4393,34 +4288,36 @@ export default {
|
||||
insightPanelCollapsed.value = !insightPanelCollapsed.value
|
||||
}
|
||||
|
||||
function switchReviewDrawerMode(mode) {
|
||||
if (reviewDrawerMode.value === mode) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value = mode
|
||||
}
|
||||
|
||||
function switchToReviewOverviewDrawer() {
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
|
||||
}
|
||||
|
||||
function toggleReviewDocumentDrawer() {
|
||||
if (!reviewDocumentDrawerAvailable.value) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value =
|
||||
reviewDrawerMode.value === REVIEW_DRAWER_MODE_DOCUMENTS
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: REVIEW_DRAWER_MODE_DOCUMENTS
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_DOCUMENTS)
|
||||
}
|
||||
|
||||
function toggleReviewRiskDrawer() {
|
||||
if (!reviewRiskDrawerAvailable.value) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value =
|
||||
reviewDrawerMode.value === REVIEW_DRAWER_MODE_RISK
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: REVIEW_DRAWER_MODE_RISK
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
|
||||
}
|
||||
|
||||
function toggleReviewFlowDrawer() {
|
||||
if (!reviewFlowDrawerAvailable.value) {
|
||||
return
|
||||
}
|
||||
reviewDrawerMode.value =
|
||||
reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW
|
||||
? REVIEW_DRAWER_MODE_REVIEW
|
||||
: REVIEW_DRAWER_MODE_FLOW
|
||||
switchReviewDrawerMode(REVIEW_DRAWER_MODE_FLOW)
|
||||
}
|
||||
|
||||
function setInlineReviewFieldError(key, message) {
|
||||
@@ -5335,6 +5232,7 @@ export default {
|
||||
activeReviewPayload,
|
||||
activeReviewFilePreviews,
|
||||
reviewDrawerMode,
|
||||
isReviewOverviewDrawer,
|
||||
isReviewDocumentDrawer,
|
||||
isReviewRiskDrawer,
|
||||
isReviewFlowDrawer,
|
||||
@@ -5433,6 +5331,7 @@ export default {
|
||||
resolveFlowStepStatusLabel,
|
||||
resolveFlowStepDetail,
|
||||
toggleInsightPanel,
|
||||
switchToReviewOverviewDrawer,
|
||||
toggleReviewDocumentDrawer,
|
||||
toggleReviewRiskDrawer,
|
||||
toggleReviewFlowDrawer,
|
||||
|
||||
@@ -3,7 +3,9 @@ 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,
|
||||
@@ -15,8 +17,13 @@ import {
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import { canManageExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
|
||||
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
|
||||
import {
|
||||
buildAiAdviceViewModel,
|
||||
buildAttachmentInsightViewModel,
|
||||
buildAttachmentRiskCards
|
||||
} from './travelRequestDetailInsights.js'
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
@@ -30,21 +37,6 @@ const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
meal_receipt: '餐饮票据',
|
||||
office_invoice: '办公用品票据',
|
||||
meeting_invoice: '会议/会务票据',
|
||||
training_invoice: '培训票据',
|
||||
vat_invoice: '增值税发票',
|
||||
receipt: '一般收据/凭证',
|
||||
other: '其他单据'
|
||||
}
|
||||
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'meeting',
|
||||
@@ -72,18 +64,10 @@ function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[String(value || '').trim()] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
|
||||
function resolveLocationInputPlaceholder(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '输入业务地点' : '输入采购/收货地点(可选)'
|
||||
}
|
||||
|
||||
function resolveLocationSummaryLabel(value) {
|
||||
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
|
||||
}
|
||||
@@ -191,9 +175,28 @@ function normalizeIsoDateValue(value) {
|
||||
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 || '支持上传 JPG、PNG、PDF,未上传也可先保存草稿'
|
||||
return normalized || '仅支持上传 1 张 JPG、PNG、PDF 单据'
|
||||
}
|
||||
|
||||
function extractAttachmentDisplayName(value) {
|
||||
@@ -216,6 +219,12 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
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: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
|
||||
@@ -226,6 +235,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: requestModel?.detailVariant === 'travel' ? `第 ${index + 1} 项` : '业务发生项',
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
@@ -234,7 +244,7 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
amount: amountDisplay,
|
||||
status: attachments.length ? '已识别' : '待补充',
|
||||
tone: attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: attachments.length ? `${attachments.length} 份附件` : '未上传',
|
||||
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
@@ -372,12 +382,21 @@ function mapIssueToAdvice(issue) {
|
||||
export default {
|
||||
name: 'TravelRequestDetailView',
|
||||
components: {
|
||||
ConfirmDialog
|
||||
ConfirmDialog,
|
||||
ReturnReasonDialog
|
||||
},
|
||||
props: {
|
||||
request: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
backLabel: {
|
||||
type: String,
|
||||
default: '返回报销列表'
|
||||
},
|
||||
approvalMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['backToRequests', 'openAssistant', 'request-updated', 'request-deleted'],
|
||||
@@ -392,10 +411,14 @@ export default {
|
||||
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)
|
||||
@@ -404,6 +427,7 @@ export default {
|
||||
const attachmentPreviewUrl = ref('')
|
||||
const attachmentPreviewName = ref('')
|
||||
const attachmentPreviewMediaType = ref('')
|
||||
const attachmentPreviewItemId = ref('')
|
||||
const expenseEditor = reactive({
|
||||
itemDate: '',
|
||||
itemType: 'other',
|
||||
@@ -455,13 +479,28 @@ export default {
|
||||
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 showLeaderApprovalPanel = computed(() =>
|
||||
Boolean(props.approvalMode)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& isDirectManagerApprovalStage.value
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const canReturnRequest = computed(() =>
|
||||
canManageCurrentClaim.value
|
||||
canReturnExpenseClaims(currentUser.value)
|
||||
&& request.value.approvalKey === 'in_progress'
|
||||
&& Boolean(request.value.claimId)
|
||||
)
|
||||
const canApproveRequest = computed(() =>
|
||||
showLeaderApprovalPanel.value
|
||||
&& canReturnExpenseClaims(currentUser.value)
|
||||
)
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
const deleteDialogDescription = computed(() =>
|
||||
@@ -474,6 +513,7 @@ export default {
|
||||
|| submitBusy.value
|
||||
|| deleteBusy.value
|
||||
|| returnBusy.value
|
||||
|| approveBusy.value
|
||||
|| creatingExpense.value
|
||||
|| Boolean(uploadingExpenseId.value)
|
||||
|| Boolean(deletingAttachmentId.value)
|
||||
@@ -583,12 +623,8 @@ export default {
|
||||
})
|
||||
|
||||
const uploadedExpenseCount = computed(() => expenseItems.value.filter((item) => item.attachments.length).length)
|
||||
const hasExpenseRiskColumn = computed(() => expenseItems.value.some((item) => item.attachments.length))
|
||||
const expenseTableColumnCount = computed(
|
||||
() => 5 + (hasExpenseRiskColumn.value ? 1 : 0) + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const expenseSummaryText = computed(
|
||||
() => request.value.expenseTableSummary || '请继续补充票据、说明和系统校验结果。'
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const detailNote = computed(
|
||||
() =>
|
||||
@@ -599,7 +635,49 @@ export default {
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
)
|
||||
const canSubmit = computed(() => isEditableRequest.value && draftBlockingIssues.value.length === 0 && !actionBusy.value)
|
||||
const locationInputPlaceholder = computed(() => resolveLocationInputPlaceholder(expenseEditor.itemType))
|
||||
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(
|
||||
@@ -617,36 +695,13 @@ export default {
|
||||
return String(metadata?.file_name || item.attachmentHint || '').trim()
|
||||
}
|
||||
|
||||
function resolveAttachmentPreviewTitle(item) {
|
||||
const fileName = resolveAttachmentDisplayName(item)
|
||||
return fileName ? `预览附件:${fileName}` : '预览附件'
|
||||
}
|
||||
|
||||
function resolveAttachmentRecognition(item) {
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
const documentInfo = metadata?.document_info
|
||||
const requirementCheck = metadata?.requirement_check
|
||||
if (!documentInfo && !requirementCheck) {
|
||||
return null
|
||||
}
|
||||
|
||||
const fields = Array.isArray(documentInfo?.fields)
|
||||
? documentInfo.fields
|
||||
.map((field) => ({
|
||||
label: String(field?.label || '').trim(),
|
||||
value: String(field?.value || '').trim()
|
||||
}))
|
||||
.filter((field) => field.label && field.value)
|
||||
: []
|
||||
|
||||
return {
|
||||
documentTypeLabel:
|
||||
String(documentInfo?.document_type_label || '').trim()
|
||||
|| resolveDocumentTypeLabel(documentInfo?.document_type),
|
||||
requirementLabel: requirementCheck
|
||||
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
|
||||
: '待校验附件类型',
|
||||
requirementTone: requirementCheck
|
||||
? (requirementCheck.matches ? 'pass' : 'high')
|
||||
: 'medium',
|
||||
message: String(requirementCheck?.message || '').trim(),
|
||||
fields: fields.slice(0, 4).map((field) => `${field.label}:${field.value}`)
|
||||
}
|
||||
return buildAttachmentInsightViewModel(resolveAttachmentMeta(item), item)
|
||||
}
|
||||
|
||||
function buildAttachmentRiskNotice(attachment) {
|
||||
@@ -676,7 +731,7 @@ export default {
|
||||
|
||||
function canPreviewAttachment(item) {
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
return Boolean(item.invoiceId && metadata?.previewable)
|
||||
return Boolean(item.invoiceId && metadata?.previewable !== false)
|
||||
}
|
||||
|
||||
function revokeAttachmentPreviewUrl() {
|
||||
@@ -692,6 +747,7 @@ export default {
|
||||
attachmentPreviewError.value = ''
|
||||
attachmentPreviewName.value = ''
|
||||
attachmentPreviewMediaType.value = ''
|
||||
attachmentPreviewItemId.value = ''
|
||||
revokeAttachmentPreviewUrl()
|
||||
}
|
||||
|
||||
@@ -769,42 +825,16 @@ export default {
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
|
||||
const riskItems = expenseItems.value
|
||||
.map((item, index) => {
|
||||
const state = resolveExpenseRiskState(item)
|
||||
if (!state || !['medium', 'high'].includes(state.tone)) {
|
||||
return ''
|
||||
}
|
||||
const riskCards = buildAttachmentRiskCards({
|
||||
expenseItems: expenseItems.value,
|
||||
attachmentMetaByItemId: expenseAttachmentMeta,
|
||||
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
|
||||
})
|
||||
|
||||
const adviceText = String(state.suggestion || state.summary || '').trim()
|
||||
const prefix = state.tone === 'high' ? '优先整改' : '继续核对'
|
||||
return `第 ${index + 1} 条附件需${prefix}:${adviceText || '请根据系统提示补充或更换附件。'}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (!completionItems.length && !riskItems.length) {
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items: [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const hasHighRisk = expenseItems.value.some((item) => resolveExpenseRiskState(item)?.tone === 'high')
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待补信息',
|
||||
summary: completionItems.length
|
||||
? '建议先补齐必填信息,再处理附件核验项,完成后即可提交审批。'
|
||||
: '草稿信息已基本齐全,建议先处理附件风险后再提交审批。',
|
||||
items: [...completionItems, ...riskItems]
|
||||
}
|
||||
return buildAiAdviceViewModel({
|
||||
completionItems,
|
||||
riskCards
|
||||
})
|
||||
})
|
||||
|
||||
function startExpenseEdit(item) {
|
||||
@@ -836,12 +866,6 @@ export default {
|
||||
if (isPlaceholderValue(expenseEditor.itemReason)) {
|
||||
return '请输入费用说明。'
|
||||
}
|
||||
if (
|
||||
isLocationRequiredExpenseType(expenseEditor.itemType)
|
||||
&& isPlaceholderValue(expenseEditor.itemLocation)
|
||||
) {
|
||||
return '请输入业务地点。'
|
||||
}
|
||||
|
||||
const amount = Number(expenseEditor.itemAmount)
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
@@ -890,7 +914,12 @@ export default {
|
||||
}
|
||||
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法上传附件。')
|
||||
toast('当前草稿缺少 claimId,暂时无法上传单据。')
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -901,22 +930,29 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function openAttachmentPreview(item) {
|
||||
if (!request.value.claimId || !canPreviewAttachment(item)) {
|
||||
async function loadAttachmentPreview(item) {
|
||||
if (!request.value.claimId || !item?.invoiceId) {
|
||||
return
|
||||
}
|
||||
|
||||
closeAttachmentPreview()
|
||||
attachmentPreviewOpen.value = true
|
||||
attachmentPreviewLoading.value = true
|
||||
attachmentPreviewError.value = ''
|
||||
attachmentPreviewItemId.value = item.id
|
||||
attachmentPreviewName.value = resolveAttachmentDisplayName(item)
|
||||
const metadata = resolveAttachmentMeta(item)
|
||||
attachmentPreviewMediaType.value =
|
||||
String(metadata?.preview_kind || '').trim() === 'image'
|
||||
? 'image/png'
|
||||
: String(metadata?.media_type || '').trim()
|
||||
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)
|
||||
@@ -928,11 +964,48 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
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?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
return
|
||||
}
|
||||
|
||||
uploadingExpenseId.value = item.id
|
||||
|
||||
try {
|
||||
@@ -986,7 +1059,9 @@ export default {
|
||||
|
||||
async function handleExpenseFileChange(event) {
|
||||
const target = event?.target
|
||||
const file = target?.files?.[0]
|
||||
const fileList = target?.files
|
||||
const fileCount = fileList?.length || 0
|
||||
const file = fileList?.[0]
|
||||
const itemId = pendingUploadExpenseId.value
|
||||
pendingUploadExpenseId.value = ''
|
||||
|
||||
@@ -994,6 +1069,11 @@ export default {
|
||||
target.value = ''
|
||||
}
|
||||
|
||||
if (fileCount > 1) {
|
||||
toast('一条费用明细只能上传一张单据,请只选择一个文件。')
|
||||
return
|
||||
}
|
||||
|
||||
if (!file || !itemId) {
|
||||
return
|
||||
}
|
||||
@@ -1059,11 +1139,12 @@ export default {
|
||||
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: expenseEditor.itemLocation.trim(),
|
||||
item_location: preservedLocation,
|
||||
item_amount: Number(expenseEditor.itemAmount),
|
||||
invoice_id: nextInvoiceId
|
||||
})
|
||||
@@ -1071,7 +1152,7 @@ export default {
|
||||
itemDate: expenseEditor.itemDate,
|
||||
itemType: expenseEditor.itemType,
|
||||
itemReason: expenseEditor.itemReason.trim(),
|
||||
itemLocation: expenseEditor.itemLocation.trim(),
|
||||
itemLocation: preservedLocation,
|
||||
itemAmount: Number(expenseEditor.itemAmount),
|
||||
invoiceId: nextInvoiceId
|
||||
})
|
||||
@@ -1096,7 +1177,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
function handleSubmit() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||||
return
|
||||
@@ -1107,6 +1188,30 @@ export default {
|
||||
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)
|
||||
@@ -1119,6 +1224,7 @@ export default {
|
||||
} else {
|
||||
toast(`${request.value.id} 提交结果已更新。`)
|
||||
}
|
||||
submitConfirmDialogOpen.value = false
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '提交审批失败,请稍后重试。')
|
||||
@@ -1190,7 +1296,7 @@ export default {
|
||||
returnDialogOpen.value = false
|
||||
}
|
||||
|
||||
async function confirmReturnRequest() {
|
||||
async function confirmReturnRequest(payload) {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前单据缺少 claimId,暂时无法退回。')
|
||||
return
|
||||
@@ -1198,9 +1304,7 @@ export default {
|
||||
|
||||
returnBusy.value = true
|
||||
try {
|
||||
await returnExpenseClaim(request.value.claimId, {
|
||||
reason: '详情页退回,请申请人调整后重新提交。'
|
||||
})
|
||||
await returnExpenseClaim(request.value.claimId, payload)
|
||||
returnDialogOpen.value = false
|
||||
toast(`${request.value.id} 已退回待提交。`)
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
@@ -1211,7 +1315,62 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
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(`${request.value.id} 已审批通过,流转至财务审批。`)
|
||||
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: '',
|
||||
@@ -1229,21 +1388,33 @@ export default {
|
||||
actionBusy,
|
||||
aiAdvice,
|
||||
attachmentPreviewError,
|
||||
attachmentPreviewIndexLabel,
|
||||
attachmentPreviewLoading,
|
||||
attachmentPreviewMediaType,
|
||||
attachmentPreviewName,
|
||||
attachmentPreviewOpen,
|
||||
attachmentPreviewUrl,
|
||||
approveBusy,
|
||||
approveConfirmDialogOpen,
|
||||
canDeleteRequest,
|
||||
canManageCurrentClaim,
|
||||
canNavigateAttachmentPreview,
|
||||
canOpenAiEntry,
|
||||
canApproveRequest,
|
||||
canReturnRequest,
|
||||
canSubmit,
|
||||
canPreviewAttachment,
|
||||
closeApproveConfirmDialog,
|
||||
closeDeleteDialog,
|
||||
closeAttachmentPreview,
|
||||
closeSubmitConfirmDialog,
|
||||
closeReturnDialog,
|
||||
confirmApproveRequest,
|
||||
confirmDeleteRequest,
|
||||
confirmSubmitRequest,
|
||||
confirmReturnRequest,
|
||||
currentAttachmentPreviewInsight,
|
||||
currentAttachmentPreviewRiskCards,
|
||||
currentProgressRingMotion,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
@@ -1258,39 +1429,43 @@ export default {
|
||||
creatingExpense,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseSummaryText,
|
||||
expenseTableColumnCount,
|
||||
expenseTotal,
|
||||
expenseUploadInput,
|
||||
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
|
||||
handleAddExpenseItem,
|
||||
handleApproveRequest,
|
||||
handleDeleteRequest,
|
||||
handleExpenseFileChange,
|
||||
handleReturnRequest,
|
||||
handleSubmit,
|
||||
hasExpenseRiskColumn,
|
||||
heroFactItems,
|
||||
isDraftRequest,
|
||||
isEditableRequest,
|
||||
isTravelRequest,
|
||||
locationInputPlaceholder,
|
||||
openAiEntry,
|
||||
openAttachmentPreview,
|
||||
goToNextAttachmentPreview,
|
||||
goToPreviousAttachmentPreview,
|
||||
profile,
|
||||
progressSteps,
|
||||
removeExpenseItem,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
savingExpenseId,
|
||||
showLeaderApprovalPanel,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
submitBusy,
|
||||
submitConfirmDialogOpen,
|
||||
triggerExpenseUpload,
|
||||
uploadedExpenseCount,
|
||||
uploadingExpenseId,
|
||||
|
||||
290
web/src/views/scripts/travelRequestDetailInsights.js
Normal file
290
web/src/views/scripts/travelRequestDetailInsights.js
Normal file
@@ -0,0 +1,290 @@
|
||||
const DOCUMENT_TYPE_LABELS = {
|
||||
flight_itinerary: '机票/航班行程单',
|
||||
train_ticket: '火车/高铁票',
|
||||
hotel_invoice: '酒店住宿票据',
|
||||
taxi_receipt: '出租车/网约车票据',
|
||||
parking_toll_receipt: '停车/通行费票据',
|
||||
meal_receipt: '餐饮票据',
|
||||
office_invoice: '办公用品票据',
|
||||
meeting_invoice: '会议/会务票据',
|
||||
training_invoice: '培训票据',
|
||||
vat_invoice: '增值税发票',
|
||||
receipt: '一般收据/凭证',
|
||||
other: '其他单据'
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function uniqueTexts(values) {
|
||||
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
|
||||
}
|
||||
|
||||
function normalizeTone(value) {
|
||||
const tone = normalizeText(value).toLowerCase()
|
||||
if (tone === 'pass') return 'pass'
|
||||
if (tone === 'high') return 'high'
|
||||
if (tone === 'medium') return 'medium'
|
||||
if (tone === 'low') return 'low'
|
||||
return 'medium'
|
||||
}
|
||||
|
||||
function resolveDocumentTypeLabel(value) {
|
||||
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
|
||||
}
|
||||
|
||||
function normalizeRuleBasis(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizeText(item)).filter(Boolean)
|
||||
}
|
||||
|
||||
const text = normalizeText(value)
|
||||
return text ? [text] : []
|
||||
}
|
||||
|
||||
export function buildAttachmentInsightViewModel(metadata, item = {}) {
|
||||
if (!metadata) {
|
||||
return null
|
||||
}
|
||||
|
||||
const documentInfo = metadata.document_info || {}
|
||||
const requirementCheck = metadata.requirement_check || null
|
||||
const analysis = metadata.analysis || null
|
||||
const documentTypeLabel =
|
||||
normalizeText(documentInfo.document_type_label) || resolveDocumentTypeLabel(documentInfo.document_type)
|
||||
const fields = Array.isArray(documentInfo.fields)
|
||||
? documentInfo.fields
|
||||
.map((field) => ({
|
||||
label: normalizeText(field?.label),
|
||||
value: normalizeText(field?.value)
|
||||
}))
|
||||
.filter((field) => field.label && field.value)
|
||||
.map((field) => `${field.label}:${field.value}`)
|
||||
: []
|
||||
const ruleBasis = uniqueTexts([
|
||||
...normalizeRuleBasis(analysis?.rule_basis || analysis?.ruleBasis),
|
||||
...normalizeRuleBasis(requirementCheck?.rule_basis || requirementCheck?.ruleBasis),
|
||||
normalizeText(requirementCheck?.message),
|
||||
documentTypeLabel ? `票据识别依据:系统将附件识别为${documentTypeLabel}。` : '',
|
||||
normalizeText(item?.name) ? `费用项目依据:当前明细为${normalizeText(item.name)}。` : ''
|
||||
])
|
||||
|
||||
return {
|
||||
fileName: normalizeText(metadata.file_name || item.attachmentHint || item.invoiceId),
|
||||
mediaType: normalizeText(metadata.media_type),
|
||||
previewable: metadata.previewable !== false,
|
||||
documentTypeLabel,
|
||||
requirementLabel: requirementCheck
|
||||
? (requirementCheck.matches ? '符合当前费用类型' : '不符合当前费用类型')
|
||||
: '待校验附件类型',
|
||||
requirementTone: requirementCheck
|
||||
? (requirementCheck.matches ? 'pass' : 'high')
|
||||
: 'medium',
|
||||
message: normalizeText(requirementCheck?.message),
|
||||
fields: fields.slice(0, 8),
|
||||
ruleBasis,
|
||||
analysis: analysis
|
||||
? {
|
||||
label: normalizeText(analysis.label) || 'AI提示',
|
||||
tone: normalizeTone(analysis.severity),
|
||||
headline: normalizeText(analysis.headline) || normalizeText(analysis.label) || 'AI提示',
|
||||
summary: normalizeText(analysis.summary),
|
||||
points: Array.isArray(analysis.points) ? analysis.points.map((point) => normalizeText(point)).filter(Boolean) : [],
|
||||
suggestion: normalizeText(analysis.suggestion)
|
||||
}
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
function buildCardSuggestion(analysis, insight) {
|
||||
return (
|
||||
normalizeText(analysis?.suggestion)
|
||||
|| normalizeText(insight?.message)
|
||||
|| '请根据规则依据核对附件和费用明细,必要时补充说明、更换附件或调整费用项目。'
|
||||
)
|
||||
}
|
||||
|
||||
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '中风险')
|
||||
|
||||
return {
|
||||
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
|
||||
tone,
|
||||
label,
|
||||
title: `第 ${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
|
||||
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
|
||||
summary: normalizeText(analysis?.summary),
|
||||
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
|
||||
suggestion: buildCardSuggestion(analysis, insight)
|
||||
}
|
||||
}
|
||||
|
||||
function parseReturnCount(flag) {
|
||||
const count = Number(flag?.return_count ?? flag?.returnCount ?? 0)
|
||||
return Number.isFinite(count) && count > 0 ? Math.floor(count) : 0
|
||||
}
|
||||
|
||||
function resolveLatestManualReturnFlag(flags) {
|
||||
const manualReturnFlags = flags.filter(
|
||||
(flag) => flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return'
|
||||
)
|
||||
if (!manualReturnFlags.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return manualReturnFlags.reduce((latest, flag) => {
|
||||
const latestCount = parseReturnCount(latest)
|
||||
const nextCount = parseReturnCount(flag)
|
||||
if (nextCount !== latestCount) {
|
||||
return nextCount > latestCount ? flag : latest
|
||||
}
|
||||
|
||||
const latestTime = Date.parse(normalizeText(latest?.created_at || latest?.createdAt))
|
||||
const nextTime = Date.parse(normalizeText(flag?.created_at || flag?.createdAt))
|
||||
if (Number.isFinite(nextTime) && (!Number.isFinite(latestTime) || nextTime >= latestTime)) {
|
||||
return flag
|
||||
}
|
||||
|
||||
return latest
|
||||
}, manualReturnFlags[0])
|
||||
}
|
||||
|
||||
function buildManualReturnRiskCard(flag) {
|
||||
if (!flag) {
|
||||
return null
|
||||
}
|
||||
|
||||
const returnCount = parseReturnCount(flag)
|
||||
const stageReturnCount = Number(flag.stage_return_count ?? flag.stageReturnCount ?? 0)
|
||||
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
|
||||
const riskPoints = Array.isArray(flag.risk_points || flag.riskPoints)
|
||||
? (flag.risk_points || flag.riskPoints).map((item) => normalizeText(item)).filter(Boolean)
|
||||
: []
|
||||
const risk = normalizeText(flag.message || flag.reason || flag.summary) || '审批人退回该单据,请补充后重新提交。'
|
||||
const ruleBasis = uniqueTexts([
|
||||
returnCount ? `累计退回 ${returnCount} 次。` : '',
|
||||
returnStage ? `本次退回环节:${returnStage}。` : '',
|
||||
stageReturnCount > 0 ? `该环节累计退回 ${Math.floor(stageReturnCount)} 次。` : '',
|
||||
...riskPoints.map((item) => `退回风险点:${item}。`)
|
||||
])
|
||||
|
||||
return {
|
||||
id: `manual-return-${returnCount || 'latest'}`,
|
||||
tone: 'medium',
|
||||
label: '退回原因',
|
||||
title: returnCount ? `第 ${returnCount} 次退回` : '审批退回',
|
||||
risk,
|
||||
summary: normalizeText(flag.reason),
|
||||
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
|
||||
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAttachmentRiskCards({
|
||||
expenseItems = [],
|
||||
attachmentMetaByItemId = {},
|
||||
claimRiskFlags = []
|
||||
} = {}) {
|
||||
const attachmentCards = expenseItems.flatMap((item, index) => {
|
||||
if (!item?.invoiceId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const metadata = attachmentMetaByItemId[item.id]
|
||||
const insight = buildAttachmentInsightViewModel(metadata, item)
|
||||
const analysis = metadata?.analysis
|
||||
const tone = normalizeTone(analysis?.severity)
|
||||
if (!analysis || !['medium', 'high'].includes(tone)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const points = Array.isArray(analysis.points) && analysis.points.length
|
||||
? analysis.points
|
||||
: [analysis.summary || analysis.headline || analysis.label]
|
||||
|
||||
return points
|
||||
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
|
||||
.filter((card) => card.risk)
|
||||
})
|
||||
|
||||
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
|
||||
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
|
||||
const claimCards = normalizedClaimRiskFlags
|
||||
.map((flag, index) => {
|
||||
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
const risk = normalizeText(flag)
|
||||
return risk
|
||||
? {
|
||||
id: `claim-risk-${index}`,
|
||||
tone: 'medium',
|
||||
label: '单据风险',
|
||||
title: '单据风险提示',
|
||||
risk,
|
||||
summary: '',
|
||||
ruleBasis: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
const tone = normalizeTone(flag.severity)
|
||||
if (!['medium', 'high'].includes(tone)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: `claim-risk-${index}`,
|
||||
tone,
|
||||
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
|
||||
title: normalizeText(flag.label) || '单据风险提示',
|
||||
risk: normalizeText(flag.message || flag.reason || flag.summary),
|
||||
summary: normalizeText(flag.summary),
|
||||
ruleBasis: normalizeRuleBasis(flag.rule_basis || flag.ruleBasis).length
|
||||
? normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
|
||||
: ['系统预审规则命中该风险提示。'],
|
||||
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (latestManualReturnCard) {
|
||||
claimCards.unshift(latestManualReturnCard)
|
||||
}
|
||||
|
||||
return [...attachmentCards, ...claimCards]
|
||||
}
|
||||
|
||||
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
|
||||
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
|
||||
const normalizedRiskCards = riskCards.filter(Boolean)
|
||||
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
|
||||
|
||||
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items: [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
],
|
||||
riskCards: []
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待核对',
|
||||
summary: normalizedRiskCards.length
|
||||
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
|
||||
: '建议先补齐必填信息,完成后即可提交审批。',
|
||||
items: normalizedCompletionItems,
|
||||
riskCards: normalizedRiskCards
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user