feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装 - 增强预算助理报告与风险建议卡片交互 - 重构登录页视觉样式与移动端响应式适配 - 优化个人工作台、文档中心、政策中心、员工管理等页面布局 - 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型 - 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
@@ -120,7 +120,9 @@ export default {
|
||||
status: '全部'
|
||||
})
|
||||
|
||||
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
|
||||
const canEditBudget = computed(() =>
|
||||
canEditBudgetCenter(props.currentUser) || isBudgetMonitorUser(props.currentUser)
|
||||
)
|
||||
const canAuditBudgetDrafts = computed(() => canEditBudgetCenter(props.currentUser))
|
||||
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
|
||||
const isDepartmentBudgetMonitor = computed(
|
||||
@@ -145,7 +147,10 @@ export default {
|
||||
})
|
||||
)
|
||||
|
||||
const budgetScopeTabs = computed(() => buildBudgetScopeTabs(budgetRowsByScope.value))
|
||||
const budgetScopeTabs = computed(() =>
|
||||
buildBudgetScopeTabs(budgetRowsByScope.value)
|
||||
.filter((tab) => canAuditBudgetDrafts.value || tab.value !== BUDGET_SCOPE_REVIEW)
|
||||
)
|
||||
const activeScopeRows = computed(() => budgetRowsByScope.value[activeBudgetScope.value] || [])
|
||||
const activeScopeLabel = computed(
|
||||
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
|
||||
@@ -224,14 +229,59 @@ export default {
|
||||
}))
|
||||
const pageSummary = computed(() => `共 ${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} 页`)
|
||||
|
||||
function openBudgetAssistant(prompt = '') {
|
||||
function buildBudgetAssistantContext(row, mode = 'edit') {
|
||||
if (!row) return null
|
||||
return {
|
||||
mode,
|
||||
budgetNo: row.budgetNo,
|
||||
departmentCode: row.departmentCode,
|
||||
departmentName: row.departmentName,
|
||||
costCenter: row.costCenter,
|
||||
periodLabel: row.periodLabel,
|
||||
periodType: row.periodType,
|
||||
budgetYear: row.budgetYear,
|
||||
budgetQuarter: row.budgetQuarter,
|
||||
version: row.version,
|
||||
compiler: row.compiler || row.owner,
|
||||
reviewer: row.reviewer,
|
||||
submittedAt: row.submittedAt,
|
||||
requestedAmount: row.requestedAmount || row.quarterAmount,
|
||||
previousAmount: row.quarterAmount,
|
||||
categoryRows: Array.isArray(row.categoryRows)
|
||||
? row.categoryRows.map((item) => ({ ...item }))
|
||||
: []
|
||||
}
|
||||
}
|
||||
|
||||
function resolveEditableBudgetRow() {
|
||||
const allRows = budgetRowsByScope.value[BUDGET_SCOPE_ALL] || []
|
||||
if (isDepartmentBudgetMonitor.value) {
|
||||
return allRows.find((row) => (
|
||||
row.scope === BUDGET_SCOPE_ALL &&
|
||||
(
|
||||
(currentUserCostCenter.value && row.costCenter === currentUserCostCenter.value) ||
|
||||
(currentUserDepartmentName.value && row.departmentName === currentUserDepartmentName.value)
|
||||
)
|
||||
)) || allRows[0] || null
|
||||
}
|
||||
|
||||
return allRows.find((row) => row.scope === BUDGET_SCOPE_ALL) || allRows[0] || null
|
||||
}
|
||||
|
||||
function openBudgetAssistant(prompt = '', budgetContext = null) {
|
||||
if (!canEditBudget.value) return
|
||||
const context = budgetContext || buildBudgetAssistantContext(resolveEditableBudgetRow(), 'edit')
|
||||
emit('openAssistant', {
|
||||
source: 'budget',
|
||||
sessionType: 'budget',
|
||||
prompt,
|
||||
prompt: prompt || (
|
||||
context?.departmentName
|
||||
? `编辑${context.departmentName}${context.periodLabel || ''}预算`
|
||||
: '编辑本部门预算'
|
||||
),
|
||||
files: [],
|
||||
conversation: null
|
||||
conversation: null,
|
||||
budgetContext: context
|
||||
})
|
||||
}
|
||||
|
||||
@@ -242,7 +292,8 @@ export default {
|
||||
}
|
||||
|
||||
openBudgetAssistant(
|
||||
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`
|
||||
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`,
|
||||
buildBudgetAssistantContext(row, 'review')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -332,6 +383,12 @@ export default {
|
||||
}
|
||||
)
|
||||
|
||||
watch(canAuditBudgetDrafts, (allowed) => {
|
||||
if (!allowed && activeBudgetScope.value === BUDGET_SCOPE_REVIEW) {
|
||||
activeBudgetScope.value = BUDGET_SCOPE_ALL
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(
|
||||
[
|
||||
budgetPageSize,
|
||||
|
||||
@@ -532,6 +532,10 @@ export default {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
initialBudgetContext: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
initialSessionType: {
|
||||
type: String,
|
||||
default: ''
|
||||
@@ -1109,6 +1113,7 @@ export default {
|
||||
submitting,
|
||||
syncComposerFilesToDraft,
|
||||
emitOperationCompleted,
|
||||
emitDraftSaved: (payload) => emit('draft-saved', payload),
|
||||
emitRequestUpdated: (payload) => emit('request-updated', payload),
|
||||
toast
|
||||
})
|
||||
@@ -1881,6 +1886,29 @@ export default {
|
||||
].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
|
||||
}
|
||||
|
||||
function shouldShowDraftSavedCard(message) {
|
||||
const draftPayload = message?.draftPayload || null
|
||||
return Boolean(
|
||||
draftPayload
|
||||
&& (
|
||||
String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
|| String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|
||||
|| String(draftPayload.title || '').trim()
|
||||
|| String(draftPayload.body || '').trim()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveReimbursementDraftClaimNo(draftPayload) {
|
||||
return String(
|
||||
draftPayload?.claim_no
|
||||
|| draftPayload?.claimNo
|
||||
|| draftPayload?.claim_id
|
||||
|| draftPayload?.claimId
|
||||
|| ''
|
||||
).trim() || '待生成'
|
||||
}
|
||||
|
||||
function updateMessageOperationFeedback(message, patch = {}) {
|
||||
if (!message?.id) {
|
||||
return
|
||||
@@ -1957,7 +1985,7 @@ export default {
|
||||
const draftPayload = message?.draftPayload || {}
|
||||
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|
||||
if (!claimId) {
|
||||
toast('暂未获取到申请单据 ID,稍后可在单据中心查看。')
|
||||
toast('暂未获取到单据 ID,稍后可在单据中心查看。')
|
||||
return
|
||||
}
|
||||
await router.push({
|
||||
@@ -2403,6 +2431,8 @@ export default {
|
||||
isApplicationDraftPayload,
|
||||
resolveApplicationDraftStatusLabel,
|
||||
buildApplicationDraftSummaryItems,
|
||||
shouldShowDraftSavedCard,
|
||||
resolveReimbursementDraftClaimNo,
|
||||
openApplicationDraftDetail,
|
||||
isOperationFeedbackVisible,
|
||||
dismissOperationFeedbackForMessage,
|
||||
@@ -2519,7 +2549,7 @@ export default {
|
||||
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
||||
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
||||
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, shouldShowDraftSavedCard, resolveReimbursementDraftClaimNo, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import TravelRequestApprovalDialog from '../../components/travel/TravelRequestAp
|
||||
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
|
||||
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
|
||||
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
|
||||
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
|
||||
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
|
||||
import {
|
||||
approveExpenseClaim,
|
||||
@@ -16,9 +15,9 @@ import {
|
||||
deleteExpenseClaimItem,
|
||||
deleteExpenseClaimItemAttachment,
|
||||
deleteExpenseClaim,
|
||||
fetchEmployeeLatestProfile,
|
||||
fetchExpenseClaimItemAttachmentMeta,
|
||||
fetchExpenseClaimItemAttachmentPreview,
|
||||
preReviewExpenseClaim,
|
||||
returnExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
@@ -33,7 +32,8 @@ import {
|
||||
canReturnExpenseClaims,
|
||||
isCurrentDirectManagerForRequest,
|
||||
isCurrentRequestApplicant,
|
||||
isFinanceUser
|
||||
isFinanceUser,
|
||||
isPlatformAdminUser
|
||||
} from '../../utils/accessControl.js'
|
||||
import {
|
||||
buildRiskViewerContext,
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
buildExpenseItemViewModel,
|
||||
buildFallbackExpenseItems,
|
||||
buildFallbackProgressSteps,
|
||||
buildOptionalTravelReceiptRiskCards,
|
||||
formatCurrency,
|
||||
isPlaceholderValue,
|
||||
isApplicationDocumentRequest,
|
||||
@@ -84,16 +83,15 @@ import {
|
||||
resolveExpenseUploadHint
|
||||
} from './travelRequestDetailExpenseModel.js'
|
||||
import {
|
||||
buildAiPreReviewSnapshot,
|
||||
findLatestAiPreReviewEvent,
|
||||
isAiPreReviewFlag,
|
||||
isAiPreReviewPassed,
|
||||
resolveAiPreReviewToast,
|
||||
resolveSubmitActionIcon,
|
||||
resolveSubmitActionLabel,
|
||||
resolveSubmitConfirmDescription,
|
||||
resolveSubmitConfirmText
|
||||
} from './travelRequestDetailPreReviewModel.js'
|
||||
} from './travelRequestDetailSubmitModel.js'
|
||||
import {
|
||||
buildEmployeeProfileAdviceItems,
|
||||
buildTravelReceiptMaterialPrompts
|
||||
} from './travelRequestDetailAdviceModel.js'
|
||||
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
|
||||
|
||||
/*
|
||||
@@ -229,50 +227,6 @@ function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelM
|
||||
|| source?.created_at
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
|
||||
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
if (isApplicationDocumentRequest(requestModel)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const isTravelContext =
|
||||
requestModel?.detailVariant === 'travel' ||
|
||||
requestModel?.typeCode === 'travel' ||
|
||||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
|
||||
if (!isTravelContext) {
|
||||
return []
|
||||
}
|
||||
|
||||
const hasUploadedType = (itemType) =>
|
||||
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
|
||||
const cards = []
|
||||
if (!hasUploadedType('hotel_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-hotel-ticket',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '住宿票据提醒',
|
||||
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
|
||||
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
|
||||
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
|
||||
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
|
||||
})
|
||||
}
|
||||
if (!hasUploadedType('ride_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-ride-ticket',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '乘车票据提醒',
|
||||
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
|
||||
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
|
||||
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
|
||||
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
if (item.isSystemGenerated) {
|
||||
@@ -394,7 +348,6 @@ export default {
|
||||
ConfirmDialog,
|
||||
EnterpriseSelect,
|
||||
StageRiskAdviceCard,
|
||||
RiskObservationEvidenceCard,
|
||||
TravelRequestApprovalDialog,
|
||||
TravelRequestBudgetAnalysis,
|
||||
TravelRequestDeleteDialog,
|
||||
@@ -426,8 +379,11 @@ export default {
|
||||
const deletingExpenseId = ref('')
|
||||
const pendingUploadExpenseId = ref('')
|
||||
const submitBusy = ref(false)
|
||||
const aiPreReviewSnapshot = ref(null)
|
||||
const riskFlagPreviewSnapshot = ref(null)
|
||||
const employeeRiskProfile = ref(null)
|
||||
const employeeRiskProfileLoading = ref(false)
|
||||
const employeeRiskProfileError = ref('')
|
||||
let employeeRiskProfileLoadSeq = 0
|
||||
const submitConfirmDialogOpen = ref(false)
|
||||
const riskOverrideDialogOpen = ref(false)
|
||||
const riskOverrideBusy = ref(false)
|
||||
@@ -507,6 +463,9 @@ export default {
|
||||
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
|
||||
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
|
||||
const canDeleteRequest = computed(() => {
|
||||
if (isApplicationDocument.value) {
|
||||
return isPlatformAdminUser(currentUser.value)
|
||||
}
|
||||
if (isArchivedRequest.value) {
|
||||
return canDeleteArchivedExpenseClaims(currentUser.value)
|
||||
}
|
||||
@@ -612,6 +571,7 @@ export default {
|
||||
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
|
||||
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
|
||||
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
|
||||
const hasSingleLeaderApprovalEvent = computed(() => leaderApprovalEvents.value.length === 1)
|
||||
const leaderApprovalReadonlyMeta = computed(() => {
|
||||
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
|
||||
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
|
||||
@@ -682,7 +642,12 @@ export default {
|
||||
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。`
|
||||
: `${request.value.id} 已审批通过,流转至财务审批。`
|
||||
})
|
||||
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
|
||||
const deleteActionLabel = computed(() => {
|
||||
if (isApplicationDocument.value) {
|
||||
return '删除申请'
|
||||
}
|
||||
return isDraftRequest.value ? '删除草稿' : '删除单据'
|
||||
})
|
||||
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
|
||||
const deleteDialogDescription = computed(() =>
|
||||
isDraftRequest.value
|
||||
@@ -726,7 +691,6 @@ export default {
|
||||
Object.keys(expenseAttachmentMeta).forEach((key) => {
|
||||
delete expenseAttachmentMeta[key]
|
||||
})
|
||||
aiPreReviewSnapshot.value = null
|
||||
closeAttachmentPreview()
|
||||
}
|
||||
pendingUploadExpenseId.value = ''
|
||||
@@ -923,15 +887,6 @@ export default {
|
||||
) {
|
||||
requestFlags = previewSnapshot.riskFlags
|
||||
}
|
||||
const snapshot = aiPreReviewSnapshot.value
|
||||
if (
|
||||
snapshot
|
||||
&& snapshot.claimId === request.value?.claimId
|
||||
&& Array.isArray(snapshot.riskFlags)
|
||||
&& !requestFlags.some(isAiPreReviewFlag)
|
||||
) {
|
||||
return snapshot.riskFlags
|
||||
}
|
||||
return requestFlags
|
||||
}
|
||||
|
||||
@@ -1093,10 +1048,6 @@ export default {
|
||||
return summary ? `重大风险警示:${summary}` : '重大风险警示'
|
||||
}
|
||||
|
||||
function applyAiPreReviewPayload(payload) {
|
||||
aiPreReviewSnapshot.value = buildAiPreReviewSnapshot(payload, request.value.claimId)
|
||||
}
|
||||
|
||||
function applyClaimRiskFlagsPayload(payload) {
|
||||
const flags = Array.isArray(payload?.claim_risk_flags)
|
||||
? payload.claim_risk_flags
|
||||
@@ -1112,11 +1063,69 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const requiresAiPreReview = computed(() => isEditableRequest.value && !isApplicationDocument.value)
|
||||
const aiPreReviewEvent = computed(() => findLatestAiPreReviewEvent(resolveClaimRiskFlags()))
|
||||
const hasAiPreReviewResult = computed(() => !requiresAiPreReview.value || Boolean(aiPreReviewEvent.value))
|
||||
const aiPreReviewPassed = computed(() =>
|
||||
isAiPreReviewPassed(aiPreReviewEvent.value, requiresAiPreReview.value)
|
||||
function resolveProfileLookupId() {
|
||||
return String(
|
||||
request.value?.profileEmployeeId
|
||||
|| request.value?.employeeId
|
||||
|| request.value?.employee_id
|
||||
|| request.value?.profileName
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolveProfileExpenseScope() {
|
||||
const typeCode = String(request.value?.typeCode || '').trim()
|
||||
return typeCode && !typeCode.endsWith('_application') ? typeCode : 'overall'
|
||||
}
|
||||
|
||||
async function loadEmployeeRiskProfile() {
|
||||
const employeeId = resolveProfileLookupId()
|
||||
if (!employeeId || isApplicationDocument.value) {
|
||||
employeeRiskProfile.value = null
|
||||
employeeRiskProfileError.value = ''
|
||||
employeeRiskProfileLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const sequence = ++employeeRiskProfileLoadSeq
|
||||
employeeRiskProfileLoading.value = true
|
||||
employeeRiskProfileError.value = ''
|
||||
try {
|
||||
const payload = await fetchEmployeeLatestProfile(employeeId, {
|
||||
scene: 'approval',
|
||||
claim_id: request.value?.claimId || '',
|
||||
window_days: 90,
|
||||
expense_type_scope: resolveProfileExpenseScope()
|
||||
})
|
||||
if (sequence === employeeRiskProfileLoadSeq) {
|
||||
employeeRiskProfile.value = payload || null
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence === employeeRiskProfileLoadSeq) {
|
||||
employeeRiskProfile.value = null
|
||||
employeeRiskProfileError.value = error?.message || '用户画像读取失败'
|
||||
}
|
||||
} finally {
|
||||
if (sequence === employeeRiskProfileLoadSeq) {
|
||||
employeeRiskProfileLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
request.value?.claimId,
|
||||
request.value?.profileEmployeeId,
|
||||
request.value?.employeeId,
|
||||
request.value?.employee_id,
|
||||
request.value?.profileName,
|
||||
request.value?.typeCode,
|
||||
isApplicationDocument.value
|
||||
].join('|'),
|
||||
() => {
|
||||
void loadEmployeeRiskProfile()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const aiAdvice = computed(() => {
|
||||
@@ -1143,19 +1152,22 @@ export default {
|
||||
}),
|
||||
currentBusinessStage
|
||||
)
|
||||
const optionalRiskCards = filterRiskCardsByBusinessStage(
|
||||
buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value),
|
||||
currentBusinessStage
|
||||
)
|
||||
const materialPrompts = currentBusinessStage === 'reimbursement'
|
||||
? buildTravelReceiptMaterialPrompts(request.value, expenseItems.value)
|
||||
: []
|
||||
const profileAdviceItems = currentBusinessStage === 'reimbursement'
|
||||
? buildEmployeeProfileAdviceItems(employeeRiskProfile.value)
|
||||
: []
|
||||
const scopedRiskCards = [
|
||||
...(hasActionableRiskCards ? [] : summaryRiskCards),
|
||||
...directRiskCards,
|
||||
...optionalRiskCards
|
||||
...directRiskCards
|
||||
]
|
||||
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
|
||||
|
||||
return buildAiAdviceViewModel({
|
||||
completionItems,
|
||||
materialPrompts,
|
||||
profileAdviceItems,
|
||||
riskCards
|
||||
})
|
||||
})
|
||||
@@ -1164,12 +1176,17 @@ export default {
|
||||
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
|
||||
)
|
||||
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
|
||||
const showCompactSafeAdvice = computed(() =>
|
||||
isEditableRequest.value
|
||||
&& !isApplicationDocument.value
|
||||
&& !draftBlockingIssues.value.length
|
||||
)
|
||||
const showAiAdvicePanel = computed(() => (
|
||||
(
|
||||
isEditableRequest.value
|
||||
&& (
|
||||
(requiresAiPreReview.value && hasAiPreReviewResult.value)
|
||||
|| hasAdviceSections.value
|
||||
hasAdviceSections.value
|
||||
|| showCompactSafeAdvice.value
|
||||
)
|
||||
)
|
||||
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|
||||
@@ -1188,24 +1205,22 @@ export default {
|
||||
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
|
||||
? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
|
||||
: isEditableRequest.value
|
||||
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : 'AI预审已完成,请按风险提示补充原因或进入下一步。')
|
||||
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。')
|
||||
: '展示系统已识别的风险点,便于审批和后续整改。'
|
||||
))
|
||||
|
||||
const submitActionLabel = computed(() => {
|
||||
return resolveSubmitActionLabel({
|
||||
isApplicationDocument: isApplicationDocument.value,
|
||||
hasAiPreReviewResult: hasAiPreReviewResult.value,
|
||||
submitBusy: submitBusy.value
|
||||
})
|
||||
})
|
||||
const submitActionIcon = computed(() => resolveSubmitActionIcon({
|
||||
isApplicationDocument: isApplicationDocument.value,
|
||||
hasAiPreReviewResult: hasAiPreReviewResult.value
|
||||
isApplicationDocument: isApplicationDocument.value
|
||||
}))
|
||||
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
|
||||
isApplicationDocument: isApplicationDocument.value,
|
||||
aiPreReviewPassed: aiPreReviewPassed.value
|
||||
hasHighRiskWarnings: submitRiskWarnings.value.length > 0
|
||||
}))
|
||||
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
|
||||
|
||||
@@ -1751,21 +1766,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function runAiPreReview() {
|
||||
submitBusy.value = true
|
||||
try {
|
||||
const payload = await preReviewExpenseClaim(request.value.claimId)
|
||||
applyAiPreReviewPayload(payload)
|
||||
const event = findLatestAiPreReviewEvent(payload?.risk_flags_json || [])
|
||||
toast(resolveAiPreReviewToast(event))
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || 'AI预审失败,请稍后重试。')
|
||||
} finally {
|
||||
submitBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法提交。')
|
||||
@@ -1782,11 +1782,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
|
||||
await runAiPreReview()
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
openRiskOverrideDialog()
|
||||
return
|
||||
@@ -1822,12 +1817,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
await runAiPreReview()
|
||||
return
|
||||
}
|
||||
|
||||
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
|
||||
submitConfirmDialogOpen.value = false
|
||||
openRiskOverrideDialog()
|
||||
@@ -1843,10 +1832,10 @@ export default {
|
||||
toast(
|
||||
isApplicationDocument.value
|
||||
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}。`
|
||||
: `${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`
|
||||
: `${request.value.id} 已提交审批${approvalStage ? `,当前节点:${approvalStage}` : '。'}`
|
||||
)
|
||||
} else if (claimStatus === 'supplement') {
|
||||
toast(`${request.value.id} AI预审未通过,已转待补充。`)
|
||||
toast(`${request.value.id} 自动检测未通过,已转待补充。`)
|
||||
} else {
|
||||
toast(`${request.value.id} 提交结果已更新。`)
|
||||
}
|
||||
@@ -2062,7 +2051,7 @@ export default {
|
||||
isMajorExpenseRisk,
|
||||
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
|
||||
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
|
||||
hasLeaderApprovalEvents, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
||||
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
|
||||
resolveExpenseRiskIndicatorTitle,
|
||||
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
|
||||
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
|
||||
|
||||
@@ -116,21 +116,105 @@ function resolvePreviousPeriod(year, quarter) {
|
||||
return { year: year - 1, quarter: 4 }
|
||||
}
|
||||
|
||||
export function shouldUseBudgetCompileReport(rawText, options = {}) {
|
||||
if (String(options.sessionType || '').trim() !== 'budget') {
|
||||
return false
|
||||
function resolveDepartmentNameFromText(rawText) {
|
||||
const text = String(rawText || '')
|
||||
const match = text.match(/(市场部|财务部|技术部|人力资源部|生产部|总裁办)/)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
function normalizeBudgetContext(context) {
|
||||
return context && typeof context === 'object' ? context : null
|
||||
}
|
||||
|
||||
function resolveContextMode(context) {
|
||||
return String(context?.mode || '').trim() === 'review' ? 'review' : 'edit'
|
||||
}
|
||||
|
||||
function buildFinanceSuggestion(item, mode) {
|
||||
if (mode !== 'review') {
|
||||
return ''
|
||||
}
|
||||
if (item.riskTone === 'risk') {
|
||||
return `${item.name}增幅较高,建议压降到可归控额度,并要求预算管理者补充业务依据。`
|
||||
}
|
||||
if (item.riskTone === 'alert') {
|
||||
return `${item.name}建议结合上一周期实际发生额复核,避免预算冗余。`
|
||||
}
|
||||
return `${item.name}预算结构基本合理,建议按提交金额形成预算。`
|
||||
}
|
||||
|
||||
function resolveSuggestedBudgetAmount(row) {
|
||||
const amount = Number(row.amount || row.budgetAmount || row.recommendedBudget || 0)
|
||||
const tone = String(row.riskTone || '').trim()
|
||||
if (tone === 'risk') return Math.round(amount * 0.92)
|
||||
if (tone === 'alert') return Math.round(amount * 0.96)
|
||||
return amount
|
||||
}
|
||||
|
||||
function buildItemsFromBudgetContext(context, fallbackItems) {
|
||||
const rows = Array.isArray(context?.categoryRows) ? context.categoryRows : []
|
||||
const mode = resolveContextMode(context)
|
||||
if (!rows.length) return fallbackItems
|
||||
|
||||
return rows.map((row, index) => {
|
||||
const fallback = fallbackItems[index] || PREVIOUS_QUARTER_SPEND[index] || {}
|
||||
const amount = Number(row.amount || fallback.recommendedBudget || 0)
|
||||
const used = Number(row.used || 0)
|
||||
const occupied = Number(row.occupied || 0)
|
||||
const value = used + occupied
|
||||
const suggestedBudget = resolveSuggestedBudgetAmount(row)
|
||||
const item = {
|
||||
key: row.code || fallback.key || `budget-${index}`,
|
||||
name: row.name || fallback.name || '预算科目',
|
||||
value,
|
||||
previousValue: Number(fallback.previousValue || 0),
|
||||
recommendedBudget: suggestedBudget,
|
||||
color: BUDGET_REPORT_COLORS[row.code] || fallback.color || BUDGET_REPORT_COLORS.travel,
|
||||
drivers: Array.isArray(fallback.drivers) ? fallback.drivers : [],
|
||||
risk: row.note || `${row.name || '该费用类型'}预算提交金额为 ${compactCurrency(amount)},已发生与已占用合计 ${compactCurrency(value)}。`,
|
||||
suggestion: buildFinanceSuggestion({ ...row, name: row.name || fallback.name, riskTone: row.riskTone }, mode),
|
||||
amountDisplay: compactCurrency(value),
|
||||
display: row.usageRateLabel || '0.0%',
|
||||
share: row.usageRateLabel || '0.0%',
|
||||
trend: row.usageRateLabel || '0.0%',
|
||||
trendTone: row.riskTone === 'risk' ? 'risk' : row.riskTone === 'alert' ? 'warn' : 'stable',
|
||||
recommendedDisplay: compactCurrency(suggestedBudget),
|
||||
editableBudget: amount,
|
||||
suggestedBudget,
|
||||
submittedNote: row.note || '',
|
||||
financeSuggestion: buildFinanceSuggestion({ ...row, name: row.name || fallback.name, riskTone: row.riskTone }, mode)
|
||||
}
|
||||
return item
|
||||
})
|
||||
}
|
||||
|
||||
export function shouldUseBudgetCompileReport(rawText, options = {}) {
|
||||
const sessionType = String(options.sessionType || '').trim()
|
||||
const entrySource = String(options.entrySource || '').trim()
|
||||
const budgetContext = normalizeBudgetContext(options.budgetContext)
|
||||
const text = normalizeBudgetText(rawText)
|
||||
const hasTargetPeriod = parseQuarter(rawText) || hasExplicitYear(rawText)
|
||||
const hasBudgetKeyword = /(预算|budget)/.test(text)
|
||||
const hasCompileKeyword = /(编制|制定|测算|生成|规划|预算一下|编辑|修改|调整|compile|create|plan|edit)/.test(text)
|
||||
const hasReviewKeyword = /(审核|复核|审预算|形成预算|回退预算|review|audit)/.test(text)
|
||||
const isBudgetContext = sessionType === 'budget' || entrySource === 'budget'
|
||||
const isWholeBudgetCompileIntent = hasBudgetKeyword && hasCompileKeyword && hasTargetPeriod
|
||||
const isBudgetContextPeriodIntent = isBudgetContext && hasBudgetKeyword && (hasTargetPeriod || hasReviewKeyword)
|
||||
const isBudgetContextEditIntent = isBudgetContext && hasBudgetKeyword && hasCompileKeyword
|
||||
|
||||
return Boolean(
|
||||
text &&
|
||||
/(预算|budget)/.test(text) &&
|
||||
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) &&
|
||||
hasTargetPeriod
|
||||
budgetContext ||
|
||||
(
|
||||
text &&
|
||||
(isWholeBudgetCompileIntent || isBudgetContextPeriodIntent || isBudgetContextEditIntent)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function buildBudgetCompileReport(rawText, user = {}) {
|
||||
export function buildBudgetCompileReport(rawText, user = {}, budgetContext = null) {
|
||||
const context = normalizeBudgetContext(budgetContext)
|
||||
const contextMode = resolveContextMode(context)
|
||||
const isReviewMode = contextMode === 'review'
|
||||
const targetYear = parseYear(rawText)
|
||||
const parsedQuarter = parseQuarter(rawText)
|
||||
const isAnnualBudget = !parsedQuarter
|
||||
@@ -142,12 +226,34 @@ export function buildBudgetCompileReport(rawText, user = {}) {
|
||||
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value * periodMultiplier, 0)
|
||||
const totalBudget = 1320000 * periodMultiplier
|
||||
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget * periodMultiplier, 0)
|
||||
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门'
|
||||
const departmentName = String(
|
||||
context?.departmentName ||
|
||||
resolveDepartmentNameFromText(rawText) ||
|
||||
user.departmentName ||
|
||||
user.department ||
|
||||
user.department_name ||
|
||||
''
|
||||
).trim() || '当前部门'
|
||||
|
||||
const items = PREVIOUS_QUARTER_SPEND.map((item) => {
|
||||
const simulatedItems = PREVIOUS_QUARTER_SPEND.map((item) => {
|
||||
const value = item.value * periodMultiplier
|
||||
const previousValue = item.previousValue * periodMultiplier
|
||||
const recommendedBudget = item.recommendedBudget * periodMultiplier
|
||||
const risk = isAnnualBudget
|
||||
? item.risk
|
||||
.replace(/Q2/g, `${previous.year}年度`)
|
||||
.replace(/Q3/g, `${targetYear}年度`)
|
||||
.replace(/季度/g, '年度')
|
||||
: item.risk
|
||||
const suggestion = isAnnualBudget
|
||||
? item.suggestion
|
||||
.replace(/Q3/g, `${targetYear}年度`)
|
||||
.replace(/季度/g, '年度')
|
||||
.replace(/52-56 万/g, '208-224 万')
|
||||
.replace(/30-32 万/g, '120-128 万')
|
||||
.replace(/19-20 万/g, '76-80 万')
|
||||
.replace(/10-11 万/g, '40-44 万')
|
||||
: item.suggestion
|
||||
const trendValue = item.previousValue
|
||||
? ((value - previousValue) / previousValue) * 100
|
||||
: 0
|
||||
@@ -166,9 +272,15 @@ export function buildBudgetCompileReport(rawText, user = {}) {
|
||||
reminderThreshold: item.key === 'communication' || item.key === 'office' ? 60 : 70,
|
||||
alertThreshold: item.key === 'communication' || item.key === 'office' ? 70 : 80,
|
||||
riskThreshold: item.key === 'communication' || item.key === 'office' ? 80 : 90,
|
||||
editNote: item.suggestion
|
||||
risk,
|
||||
suggestion,
|
||||
editNote: suggestion
|
||||
}
|
||||
})
|
||||
const items = buildItemsFromBudgetContext(context, simulatedItems)
|
||||
const reportSpend = isReviewMode
|
||||
? items.reduce((sum, item) => sum + Number(item.value || 0), 0)
|
||||
: totalSpend
|
||||
|
||||
const topItem = [...items].sort((a, b) => b.value - a.value)[0]
|
||||
const growthItem = [...items].sort((a, b) => {
|
||||
@@ -177,49 +289,69 @@ export function buildBudgetCompileReport(rawText, user = {}) {
|
||||
return bGrowth - aGrowth
|
||||
})[0]
|
||||
|
||||
const submittedBudgetTotal = items.reduce((sum, item) => sum + Number(item.editableBudget || item.recommendedBudget || 0), 0)
|
||||
const financeSuggestedTotal = items.reduce((sum, item) => sum + Number(item.suggestedBudget || item.recommendedBudget || 0), 0)
|
||||
|
||||
return {
|
||||
type: 'budget_compile_analysis',
|
||||
title: isAnnualBudget
|
||||
? `${targetYear}年度预算编制前置分析报告`
|
||||
: `${targetYear}年${targetQuarter}季度预算编制前置分析报告`,
|
||||
subtitle: isAnnualBudget
|
||||
? `基于${previous.year}年度预算执行模拟数据`
|
||||
: `基于${previous.year}年${previous.quarter}季度预算执行模拟数据`,
|
||||
mode: contextMode,
|
||||
title: isReviewMode
|
||||
? `${departmentName}${context?.periodLabel || ''}预算审核分析报告`
|
||||
: isAnnualBudget
|
||||
? `${targetYear}年度预算编制前置分析报告`
|
||||
: `${targetYear}年${targetQuarter}季度预算编制前置分析报告`,
|
||||
subtitle: isReviewMode
|
||||
? `${context?.budgetNo || '部门提交预算'} / ${context?.version || '待审核版本'}`
|
||||
: isAnnualBudget
|
||||
? `基于${previous.year}年度预算执行模拟数据`
|
||||
: `基于${previous.year}年${previous.quarter}季度预算执行模拟数据`,
|
||||
departmentName,
|
||||
targetPeriod: isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${QUARTER_NAME_MAP[targetQuarter]}`,
|
||||
targetPeriod: context?.periodLabel || (isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${QUARTER_NAME_MAP[targetQuarter]}`),
|
||||
basePeriod: isAnnualBudget ? `${previous.year}年度` : `${previous.year}年${QUARTER_NAME_MAP[previous.quarter]}`,
|
||||
periodType: isAnnualBudget ? '年度预算' : '季度预算',
|
||||
centerValue: compactCurrency(totalSpend),
|
||||
centerValue: compactCurrency(reportSpend),
|
||||
centerLabel: isAnnualBudget ? '去年开销' : '上季度开销',
|
||||
summary: {
|
||||
totalBudget: compactCurrency(totalBudget),
|
||||
totalSpend: compactCurrency(totalSpend),
|
||||
usageRate: percent(totalSpend, totalBudget),
|
||||
recommendedTotal: compactCurrency(recommendedTotal)
|
||||
totalBudget: compactCurrency(isReviewMode ? submittedBudgetTotal : totalBudget),
|
||||
totalSpend: compactCurrency(reportSpend),
|
||||
usageRate: percent(reportSpend, isReviewMode ? submittedBudgetTotal : totalBudget),
|
||||
recommendedTotal: compactCurrency(isReviewMode ? financeSuggestedTotal : recommendedTotal)
|
||||
},
|
||||
macroInsights: [
|
||||
`${isAnnualBudget ? `${previous.year}年度` : `${previous.year}年${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
|
||||
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${targetQuarter}季度`}预算编制的第一优先级。`,
|
||||
`${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
|
||||
isReviewMode
|
||||
? `${departmentName}本次提交预算 ${compactCurrency(submittedBudgetTotal)},AI 建议可归控预算 ${compactCurrency(financeSuggestedTotal)},请高级财务人员确认是否需要回退调整。`
|
||||
: `${isAnnualBudget ? `${previous.year}年度` : `${previous.year}年${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
|
||||
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isReviewMode ? '审核重点' : `${isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${targetQuarter}季度`}预算编制的第一优先级`}。`,
|
||||
isReviewMode
|
||||
? `${growthItem.name}需要重点核对预算说明、业务依据和可归控空间;如果建议预算低于提交预算,应写明回退理由。`
|
||||
: `${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
|
||||
],
|
||||
items,
|
||||
editableDraft: {
|
||||
status: 'editing',
|
||||
mode: contextMode,
|
||||
departmentName,
|
||||
rows: items.map((item) => ({
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
departmentName,
|
||||
budgetAmount: item.editableBudget,
|
||||
reminderThreshold: item.reminderThreshold,
|
||||
alertThreshold: item.alertThreshold,
|
||||
riskThreshold: item.riskThreshold,
|
||||
note: item.editNote
|
||||
suggestedBudget: item.suggestedBudget || item.recommendedBudget || item.editableBudget,
|
||||
submittedNote: item.submittedNote || item.editNote,
|
||||
financeSuggestion: item.financeSuggestion || ''
|
||||
}))
|
||||
},
|
||||
recommendations: [
|
||||
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
|
||||
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
|
||||
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
|
||||
],
|
||||
recommendations: isReviewMode
|
||||
? [
|
||||
'审核时先看预算管理者提交说明是否覆盖业务增长、已占用事项和可归控边界。',
|
||||
'建议预算低于提交预算时,应在“建议”列写明压降原因,并回退预算给预算管理者再次编辑。',
|
||||
'如果建议预算与提交预算一致且说明充分,可以直接形成正式预算。'
|
||||
]
|
||||
: [
|
||||
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}年${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
|
||||
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
|
||||
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
|
||||
],
|
||||
generatedAt: '模拟数据 · 用于 Demo 预览'
|
||||
}
|
||||
}
|
||||
@@ -243,20 +375,31 @@ export async function handleBudgetCompileReportSubmit(runtime) {
|
||||
rawText,
|
||||
replaceMessage,
|
||||
resetFlowRun,
|
||||
refreshCurrentUserFromBackend,
|
||||
budgetContext,
|
||||
scrollToBottom,
|
||||
startFlowStep,
|
||||
submitting,
|
||||
userText
|
||||
} = runtime
|
||||
const analysisStartedAt = Date.now()
|
||||
const context = normalizeBudgetContext(budgetContext)
|
||||
const isReviewRequest = resolveContextMode(context) === 'review'
|
||||
const isAnnualRequest = hasExplicitYear(rawText) && !parseQuarter(rawText)
|
||||
const basePeriodLabel = isReviewRequest ? '部门提交预算分析' : isAnnualRequest ? '去年预算开销分析' : '上季度预算开销分析'
|
||||
const recommendationLabel = isReviewRequest ? '高级财务审核建议生成' : isAnnualRequest ? '年度预算编制建议生成' : '预算编制建议生成'
|
||||
resetFlowRun()
|
||||
startFlowStep('budget-prior-quarter-analysis', {
|
||||
title: '上季度预算开销分析',
|
||||
title: basePeriodLabel,
|
||||
tool: 'budget.analysis.previous_quarter',
|
||||
detail: '正在汇总上季度费用占比、增长点和下一季度编制建议...'
|
||||
detail: isReviewRequest
|
||||
? '正在读取部门提交预算表,分析费用结构、历史消耗和可归控空间...'
|
||||
: isAnnualRequest
|
||||
? '正在汇总去年费用占比、增长点和年度预算编制建议...'
|
||||
: '正在汇总上季度费用占比、增长点和下一季度编制建议...'
|
||||
})
|
||||
startFlowStep('budget-compile-guidance', {
|
||||
title: '预算编制建议生成',
|
||||
title: recommendationLabel,
|
||||
tool: 'budget.compile.recommendation',
|
||||
detail: '正在生成预算编制前置分析报告...'
|
||||
})
|
||||
@@ -265,7 +408,11 @@ export async function handleBudgetCompileReportSubmit(runtime) {
|
||||
}
|
||||
const pendingMessage = createMessage(
|
||||
'assistant',
|
||||
'我先不直接进入预算表单,先执行上季度预算开销结构分析,再给您一版下一季度预算编制建议。',
|
||||
isReviewRequest
|
||||
? '我先加载部门提交的预算表,结合费用结构和预算说明生成高级财务审核建议。'
|
||||
: isAnnualRequest
|
||||
? '我先不直接进入预算表单,先执行去年预算开销结构分析,再给您一版年度预算编制建议。'
|
||||
: '我先不直接进入预算表单,先执行上季度预算开销结构分析,再给您一版下一季度预算编制建议。',
|
||||
[],
|
||||
{ meta: ['预算分析中'] }
|
||||
)
|
||||
@@ -286,20 +433,36 @@ export async function handleBudgetCompileReportSubmit(runtime) {
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 360))
|
||||
const budgetReport = buildBudgetCompileReport(rawText, currentUser.value || {})
|
||||
let reportUser = currentUser.value || {}
|
||||
const hasUserDepartment = String(
|
||||
reportUser.departmentName || reportUser.department || reportUser.department_name || ''
|
||||
).trim()
|
||||
if (!hasUserDepartment && typeof refreshCurrentUserFromBackend === 'function') {
|
||||
await refreshCurrentUserFromBackend({ silent: true })
|
||||
reportUser = currentUser.value || reportUser
|
||||
}
|
||||
const budgetReport = buildBudgetCompileReport(rawText, reportUser, context)
|
||||
completeFlowStep(
|
||||
'budget-prior-quarter-analysis',
|
||||
'已完成上季度费用占比、增长点和风险点分析',
|
||||
isReviewRequest
|
||||
? '已完成部门提交预算、费用结构和风险点分析'
|
||||
: isAnnualRequest
|
||||
? '已完成去年费用占比、增长点和风险点分析'
|
||||
: '已完成上季度费用占比、增长点和风险点分析',
|
||||
Date.now() - analysisStartedAt
|
||||
)
|
||||
completeFlowStep(
|
||||
'budget-compile-guidance',
|
||||
'已生成下一季度预算编制建议',
|
||||
isReviewRequest ? '已生成高级财务审核建议' : isAnnualRequest ? '已生成年度预算编制建议' : '已生成下一季度预算编制建议',
|
||||
Date.now() - analysisStartedAt
|
||||
)
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
'下面先按上季度模拟数据做一版预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。',
|
||||
isReviewRequest
|
||||
? '下面先按部门提交的预算草案做一版审核分析。正式接入预算池后,这里会替换成真实提交记录、历史消耗和归控建议。'
|
||||
: isAnnualRequest
|
||||
? '下面先按去年模拟数据做一版年度预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。'
|
||||
: '下面先按上季度模拟数据做一版预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。',
|
||||
[],
|
||||
{
|
||||
meta: ['预算分析报告', '模拟数据'],
|
||||
|
||||
@@ -73,6 +73,34 @@ function normalizeApplicationDate(claim) {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeApplicationDateText(value) {
|
||||
const text = normalizeText(value)
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const matched = text.match(/^(\d{4}-\d{2}-\d{2})/)
|
||||
return matched?.[1] || text
|
||||
}
|
||||
|
||||
function normalizeApplicationBusinessTime(claim) {
|
||||
const start = normalizeApplicationDateText(claim?.start_date || claim?.startDate || claim?.begin_date || claim?.beginDate)
|
||||
const end = normalizeApplicationDateText(claim?.end_date || claim?.endDate || claim?.finish_date || claim?.finishDate)
|
||||
if (start && end && start !== end) {
|
||||
return `${start} 至 ${end}`
|
||||
}
|
||||
return normalizeApplicationDateText(
|
||||
start
|
||||
|| claim?.business_time
|
||||
|| claim?.businessTime
|
||||
|| claim?.time_range
|
||||
|| claim?.timeRange
|
||||
|| claim?.occurred_at
|
||||
|| claim?.occurredAt
|
||||
|| claim?.occurred_date
|
||||
|| claim?.occurredDate
|
||||
)
|
||||
}
|
||||
|
||||
function toTimestamp(value) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
||||
@@ -216,6 +244,7 @@ export function normalizeRequiredApplicationCandidate(claim) {
|
||||
location,
|
||||
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
|
||||
amount_label: amountText,
|
||||
business_time: normalizeApplicationBusinessTime(claim),
|
||||
status,
|
||||
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
|
||||
application_date: normalizeApplicationDate(claim)
|
||||
@@ -247,6 +276,7 @@ export function buildRequiredApplicationActions(applications, actionType) {
|
||||
const claimNo = normalizeText(application.claim_no) || '未编号申请单'
|
||||
const description = [
|
||||
application.status_label,
|
||||
application.business_time && `时间:${application.business_time}`,
|
||||
application.location && `地点:${application.location}`,
|
||||
application.amount_label && `预算:${application.amount_label}`,
|
||||
application.reason && `事由:${application.reason}`
|
||||
@@ -265,6 +295,7 @@ export function buildRequiredApplicationActions(applications, actionType) {
|
||||
application_location: application.location,
|
||||
application_amount: application.amount,
|
||||
application_amount_label: application.amount_label,
|
||||
application_business_time: application.business_time,
|
||||
application_status: application.status,
|
||||
application_status_label: application.status_label,
|
||||
application_date: application.application_date
|
||||
|
||||
@@ -160,6 +160,12 @@ export const FLOW_STEP_FALLBACKS = {
|
||||
runningText: '正在把已确认信息保存为草稿...',
|
||||
completedText: '草稿已保存'
|
||||
},
|
||||
'draft-risk-review': {
|
||||
title: '草稿风险识别',
|
||||
tool: 'RuleEngine',
|
||||
runningText: '正在对草稿执行规则校验...',
|
||||
completedText: '已完成草稿风险识别'
|
||||
},
|
||||
'application-submit-success': {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
|
||||
@@ -110,6 +110,20 @@ function normalizeValues(values) {
|
||||
}, {})
|
||||
}
|
||||
|
||||
function hasLinkedApplication(values) {
|
||||
return Boolean(normalizeText(values?.application_claim_id) || normalizeText(values?.application_claim_no))
|
||||
}
|
||||
|
||||
function buildApplicationSummaryParts(values) {
|
||||
return [
|
||||
normalizeText(values?.application_claim_no),
|
||||
normalizeText(values?.application_reason),
|
||||
normalizeText(values?.application_business_time),
|
||||
normalizeText(values?.application_location),
|
||||
normalizeText(values?.application_amount_label || values?.application_amount)
|
||||
].filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeApplicationCandidates(applications) {
|
||||
if (!Array.isArray(applications)) {
|
||||
return []
|
||||
@@ -125,6 +139,7 @@ function normalizeApplicationCandidates(applications) {
|
||||
location: normalizeText(item.location || item.application_location),
|
||||
amount: normalizeText(item.amount || item.application_amount),
|
||||
amount_label: normalizeText(item.amount_label || item.application_amount_label),
|
||||
business_time: normalizeText(item.business_time || item.application_business_time),
|
||||
status: normalizeText(item.status || item.application_status),
|
||||
status_label: normalizeText(item.status_label || item.application_status_label),
|
||||
application_date: normalizeText(item.application_date)
|
||||
@@ -238,7 +253,6 @@ export function waitForGuidedApplicationSelection(state, expenseType, applicatio
|
||||
|
||||
export function selectGuidedRequiredApplication(state, application = {}) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||
return {
|
||||
...current,
|
||||
values: normalizeValues({
|
||||
@@ -249,9 +263,11 @@ export function selectGuidedRequiredApplication(state, application = {}) {
|
||||
application_location: application.application_location || application.location || '',
|
||||
application_amount: application.application_amount || application.amount || '',
|
||||
application_amount_label: application.application_amount_label || application.amount_label || '',
|
||||
application_status_label: application.application_status_label || application.status_label || ''
|
||||
application_business_time: application.application_business_time || application.business_time || '',
|
||||
application_status_label: application.application_status_label || application.status_label || '',
|
||||
application_date: application.application_date || ''
|
||||
}),
|
||||
stepKey: steps[0]?.key || 'summary',
|
||||
stepKey: 'summary',
|
||||
pendingInterruptionText: '',
|
||||
applicationCandidates: []
|
||||
}
|
||||
@@ -346,40 +362,41 @@ export function buildGuidedReimbursementSummaryText(state) {
|
||||
const current = normalizeGuidedFlowState(state)
|
||||
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType) || '报销'
|
||||
const steps = getGuidedReimbursementSteps(current.expenseType)
|
||||
const linkedApplication = hasLinkedApplication(current.values)
|
||||
const lines = [
|
||||
`已完成“${typeLabel}”的引导填写。`,
|
||||
'',
|
||||
'请核查下面的关键信息:'
|
||||
]
|
||||
|
||||
if (current.values.application_claim_no) {
|
||||
const applicationParts = [
|
||||
current.values.application_claim_no,
|
||||
current.values.application_reason,
|
||||
current.values.application_location,
|
||||
current.values.application_amount_label
|
||||
].filter(Boolean)
|
||||
if (linkedApplication) {
|
||||
const applicationParts = buildApplicationSummaryParts(current.values)
|
||||
lines.push(`- 关联申请单:${applicationParts.join(' / ')}`)
|
||||
lines.push('- 报销票据:可先生成草稿,随后在草稿详情中上传对应票据。')
|
||||
} else {
|
||||
steps.forEach((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (current.values.attachment_names?.length
|
||||
? current.values.attachment_names.join('、')
|
||||
: current.values.attachments || '稍后上传')
|
||||
: current.values[step.key]
|
||||
lines.push(`- ${step.summaryLabel}:${value || '待补充'}`)
|
||||
})
|
||||
}
|
||||
|
||||
steps.forEach((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (current.values.attachment_names?.length
|
||||
? current.values.attachment_names.join('、')
|
||||
: current.values.attachments || '稍后上传')
|
||||
: current.values[step.key]
|
||||
lines.push(`- ${step.summaryLabel}:${value || '待补充'}`)
|
||||
})
|
||||
|
||||
lines.push('')
|
||||
lines.push('如果这些信息无误,我可以继续生成右侧报销核对信息;生成核对信息后,再由你决定保存草稿或继续下一步。')
|
||||
lines.push(
|
||||
linkedApplication
|
||||
? '如果关联信息无误,我可以直接生成报销草稿;后续由你在草稿详情中上传和归集票据。'
|
||||
: '如果这些信息无误,我可以继续生成报销草稿;草稿生成后可继续上传票据或补充信息。'
|
||||
)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function buildGuidedReviewConfirmationActions() {
|
||||
return [{
|
||||
label: '生成报销核对信息',
|
||||
description: '进入现有报销核对流程,不会直接保存草稿',
|
||||
label: '生成报销草稿',
|
||||
description: '使用当前信息生成草稿,票据可在草稿详情继续上传',
|
||||
icon: 'mdi mdi-clipboard-check-outline',
|
||||
action_type: GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW
|
||||
}]
|
||||
@@ -390,14 +407,23 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
const type = getGuidedExpenseType(current.expenseType)
|
||||
const values = current.values || {}
|
||||
const typeLabel = type?.label || '其他费用'
|
||||
const fieldLines = getGuidedReimbursementSteps(current.expenseType).map((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
|
||||
: values[step.key]
|
||||
return `${step.summaryLabel}:${value || '待补充'}`
|
||||
})
|
||||
if (values.application_claim_no) {
|
||||
fieldLines.unshift(`关联申请单:${values.application_claim_no}`)
|
||||
const linkedApplication = hasLinkedApplication(values)
|
||||
const applicationReason = values.application_reason || ''
|
||||
const applicationLocation = values.application_location || ''
|
||||
const applicationAmount = values.application_amount || values.application_amount_label || ''
|
||||
const applicationBusinessTime = values.application_business_time || ''
|
||||
const fieldLines = []
|
||||
if (linkedApplication) {
|
||||
const applicationParts = buildApplicationSummaryParts(values)
|
||||
fieldLines.push(`关联申请单:${applicationParts.join(' / ')}`)
|
||||
fieldLines.push('报销票据:草稿生成后在详情中上传')
|
||||
} else {
|
||||
getGuidedReimbursementSteps(current.expenseType).forEach((step) => {
|
||||
const value = step.key === 'attachments'
|
||||
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
|
||||
: values[step.key]
|
||||
fieldLines.push(`${step.summaryLabel}:${value || '待补充'}`)
|
||||
})
|
||||
}
|
||||
const rawText = [
|
||||
`报销类型:${typeLabel}`,
|
||||
@@ -406,31 +432,35 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
const reviewFormValues = {
|
||||
expense_type: typeLabel,
|
||||
reimbursement_type: typeLabel,
|
||||
reason: values.reason || values.customer_name || '',
|
||||
reason_value: values.reason || '',
|
||||
reason: values.reason || applicationReason || values.customer_name || '',
|
||||
reason_value: values.reason || applicationReason || '',
|
||||
customer_name: values.customer_name || '',
|
||||
participants: values.participants || '',
|
||||
location: values.location || '',
|
||||
business_location: values.location || '',
|
||||
time_range: values.time_range || '',
|
||||
business_time: values.time_range || '',
|
||||
amount: values.amount || '',
|
||||
location: values.location || applicationLocation || '',
|
||||
business_location: values.location || applicationLocation || '',
|
||||
time_range: values.time_range || applicationBusinessTime || '',
|
||||
business_time: values.time_range || applicationBusinessTime || '',
|
||||
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
|
||||
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
|
||||
application_claim_id: values.application_claim_id || '',
|
||||
application_claim_no: values.application_claim_no || '',
|
||||
application_reason: values.application_reason || '',
|
||||
application_location: values.application_location || '',
|
||||
application_amount: values.application_amount || ''
|
||||
application_amount: values.application_amount || '',
|
||||
application_amount_label: values.application_amount_label || '',
|
||||
application_business_time: values.application_business_time || '',
|
||||
application_date: values.application_date || ''
|
||||
}
|
||||
|
||||
return {
|
||||
rawText,
|
||||
userText: '生成报销核对信息',
|
||||
pendingText: '正在生成右侧报销核对信息...',
|
||||
userText: '生成报销草稿',
|
||||
pendingText: '正在生成报销草稿...',
|
||||
systemGenerated: true,
|
||||
files,
|
||||
extraContext: {
|
||||
draft_claim_id: '',
|
||||
review_action: 'save_draft',
|
||||
user_input_text: rawText,
|
||||
expense_scene_selection: {
|
||||
expense_type: type?.key || current.expenseType || 'other',
|
||||
|
||||
@@ -1306,6 +1306,25 @@ export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
|
||||
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
|
||||
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
|
||||
|
||||
if (savedDraft) {
|
||||
const issueParts = []
|
||||
if (riskBriefs.length) {
|
||||
issueParts.push(`${riskBriefs.length} 条风险/异常提醒`)
|
||||
}
|
||||
if (pendingCount || extraMissingCount) {
|
||||
issueParts.push(`${pendingCount || extraMissingCount} 项待补充信息`)
|
||||
}
|
||||
return {
|
||||
lead: '后续处理:',
|
||||
tone: riskBriefs.length || pendingCount || extraMissingCount ? 'danger' : 'neutral',
|
||||
summary: issueParts.length
|
||||
? `自动检测识别到 ${issueParts.join('、')},请进入详情核对;如还有票据可继续上传。`
|
||||
: '自动检测暂未发现明确风险;如还有票据可继续上传。',
|
||||
items: [],
|
||||
notes: []
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingCount || extraMissingCount) {
|
||||
const summarySignature = [
|
||||
pendingCount || extraMissingCount,
|
||||
|
||||
119
web/src/views/scripts/travelRequestDetailAdviceModel.js
Normal file
119
web/src/views/scripts/travelRequestDetailAdviceModel.js
Normal file
@@ -0,0 +1,119 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function uniqueTexts(values) {
|
||||
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
|
||||
}
|
||||
|
||||
function isPlaceholderValue(value) {
|
||||
const text = normalizeText(value)
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function isApplicationDocumentRequest(requestModel) {
|
||||
const documentType = normalizeText(
|
||||
requestModel?.documentTypeCode
|
||||
|| requestModel?.document_type_code
|
||||
|| requestModel?.documentType
|
||||
|| requestModel?.document_type
|
||||
).toLowerCase()
|
||||
const claimNo = normalizeText(requestModel?.claimNo || requestModel?.claim_no || requestModel?.documentNo).toUpperCase()
|
||||
return documentType === 'application' || claimNo.startsWith('AP-') || claimNo.startsWith('APP-')
|
||||
}
|
||||
|
||||
function isHotelExpenseItem(item) {
|
||||
const text = [
|
||||
item?.itemType,
|
||||
item?.typeCode,
|
||||
item?.name,
|
||||
item?.category,
|
||||
item?.desc,
|
||||
item?.itemReason
|
||||
].map((value) => normalizeText(value)).join(' ')
|
||||
return /hotel_ticket|hotel|住宿|酒店|水单/.test(text)
|
||||
}
|
||||
|
||||
export function buildTravelReceiptMaterialPrompts(requestModel, items) {
|
||||
if (isApplicationDocumentRequest(requestModel)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const missingHotelItems = normalizedItems.filter(
|
||||
(item) => !item?.isSystemGenerated && isHotelExpenseItem(item) && isPlaceholderValue(item.invoiceId)
|
||||
)
|
||||
if (!missingHotelItems.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
`当前包含 ${missingHotelItems.length} 条住宿费用明细,但暂未关联住宿发票或酒店水单。请补充住宿材料,避免后续被退回补件。`
|
||||
]
|
||||
}
|
||||
|
||||
function profileMetric(profile, key) {
|
||||
const profiles = Array.isArray(profile?.profiles) ? profile.profiles : []
|
||||
for (const item of profiles) {
|
||||
const metrics = item?.metrics && typeof item.metrics === 'object' ? item.metrics : {}
|
||||
const value = Number(metrics[key])
|
||||
if (Number.isFinite(value) && value > 0) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function profileReviewSuggestionTexts(profile) {
|
||||
const suggestions = Array.isArray(profile?.review_suggestions)
|
||||
? profile.review_suggestions
|
||||
: Array.isArray(profile?.reviewSuggestions)
|
||||
? profile.reviewSuggestions
|
||||
: []
|
||||
return suggestions
|
||||
.map((item) => normalizeText(item?.message || item?.title || item?.label))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function profileRiskTagTexts(profile) {
|
||||
const tags = Array.isArray(profile?.profile_tags)
|
||||
? profile.profile_tags
|
||||
: Array.isArray(profile?.profileTags)
|
||||
? profile.profileTags
|
||||
: []
|
||||
return tags
|
||||
.filter((tag) => normalizeText(tag?.polarity) === 'risk')
|
||||
.map((tag) => normalizeText(tag?.reason || tag?.display_label || tag?.label))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildEmployeeProfileAdviceItems(profile) {
|
||||
if (!profile || typeof profile !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const returnCount = profileMetric(profile, 'return_count')
|
||||
const missingAttachmentCount = profileMetric(profile, 'missing_attachment_count')
|
||||
const invoiceMismatchCount = profileMetric(profile, 'invoice_mismatch_count')
|
||||
const missingContextCount = profileMetric(profile, 'missing_business_context_count')
|
||||
const items = []
|
||||
|
||||
if (returnCount > 0) {
|
||||
items.push(`历史退单建议:近 90 天存在 ${returnCount} 次退单或退回记录,提交前重点复核退回原因对应的票据、事由和说明,避免重复被退。`)
|
||||
}
|
||||
if (missingAttachmentCount > 0 || missingContextCount > 0) {
|
||||
items.push(`材料完整性建议:历史材料或业务上下文缺失累计 ${missingAttachmentCount + missingContextCount} 项,本次提交前请重点核对附件、事由、地点和补充说明。`)
|
||||
}
|
||||
if (invoiceMismatchCount > 0) {
|
||||
items.push(`票据一致性建议:历史存在 ${invoiceMismatchCount} 次票据不一致记录,本次请重点核对票据日期、城市、金额和费用明细。`)
|
||||
}
|
||||
|
||||
return uniqueTexts([
|
||||
...items,
|
||||
...profileReviewSuggestionTexts(profile),
|
||||
...profileRiskTagTexts(profile)
|
||||
]).slice(0, 4)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
])
|
||||
|
||||
export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
export const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set(['ride_ticket', 'travel_allowance'])
|
||||
export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
||||
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
@@ -88,6 +89,11 @@ export function isSystemGeneratedExpenseItemSource(source) {
|
||||
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
|
||||
export function isAttachmentRequiredExpenseItem(source) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
|
||||
return !isSystemGeneratedExpenseItemSource({ ...source, itemType }) && !OPTIONAL_ATTACHMENT_EXPENSE_TYPES.has(itemType)
|
||||
}
|
||||
|
||||
export function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
@@ -200,12 +206,11 @@ export function buildFallbackProgressSteps(requestModel = {}) {
|
||||
return [
|
||||
{ index: 1, label: '关联单据', time: hasRelatedApplication ? '已关联' : '待核对', done: hasRelatedApplication, active: true, current: !hasRelatedApplication },
|
||||
{ index: 2, label: '待提交', time: hasRelatedApplication ? '进行中' : '待处理', active: hasRelatedApplication, current: hasRelatedApplication },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
|
||||
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
|
||||
{ index: 8, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
|
||||
{ index: 3, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 4, label: '财务审批', time: '待处理' },
|
||||
{ index: 5, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
|
||||
{ index: 6, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
|
||||
{ index: 7, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -476,59 +481,13 @@ export function buildExpenseDraftIssues(item) {
|
||||
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
|
||||
issues.push('缺少金额')
|
||||
}
|
||||
if (isPlaceholderValue(item.invoiceId)) {
|
||||
if (isAttachmentRequiredExpenseItem(item) && isPlaceholderValue(item.invoiceId)) {
|
||||
issues.push('缺少票据标识')
|
||||
}
|
||||
|
||||
return issues
|
||||
}
|
||||
|
||||
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
|
||||
if (isApplicationDocumentRequest(requestModel)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizedItems = Array.isArray(items) ? items : []
|
||||
const isTravelContext =
|
||||
requestModel?.detailVariant === 'travel' ||
|
||||
requestModel?.typeCode === 'travel' ||
|
||||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
|
||||
if (!isTravelContext) {
|
||||
return []
|
||||
}
|
||||
|
||||
const hasUploadedType = (itemType) =>
|
||||
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
|
||||
const cards = []
|
||||
if (!hasUploadedType('hotel_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-hotel-ticket',
|
||||
businessStage: 'reimbursement',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '住宿票据提醒',
|
||||
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
|
||||
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
|
||||
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
|
||||
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
|
||||
})
|
||||
}
|
||||
if (!hasUploadedType('ride_ticket')) {
|
||||
cards.push({
|
||||
id: 'travel-optional-ride-ticket',
|
||||
businessStage: 'reimbursement',
|
||||
tone: 'low',
|
||||
label: '低风险',
|
||||
title: '乘车票据提醒',
|
||||
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
|
||||
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
|
||||
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
|
||||
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
|
||||
})
|
||||
}
|
||||
return cards
|
||||
}
|
||||
|
||||
export function buildDraftBlockingIssues(request, expenseItems) {
|
||||
const issues = []
|
||||
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
|
||||
|
||||
@@ -700,24 +700,30 @@ export function buildClaimSummaryRiskCards(request = {}) {
|
||||
})]
|
||||
}
|
||||
|
||||
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) {
|
||||
export function buildAiAdviceViewModel({
|
||||
completionItems = [],
|
||||
materialPrompts = [],
|
||||
profileAdviceItems = [],
|
||||
riskCards = []
|
||||
} = {}) {
|
||||
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
|
||||
const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean)
|
||||
const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean)
|
||||
const normalizedRiskCards = riskCards.filter(Boolean)
|
||||
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
|
||||
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
|
||||
|
||||
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
|
||||
const items = [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
]
|
||||
|
||||
if (
|
||||
!normalizedCompletionItems.length
|
||||
&& !normalizedMaterialPrompts.length
|
||||
&& !normalizedProfileAdviceItems.length
|
||||
&& !normalizedRiskCards.length
|
||||
) {
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items,
|
||||
badge: '可以提交',
|
||||
summary: '自动检测未发现票据、金额、行程或历史画像异常,可以提交审批。',
|
||||
items: [],
|
||||
riskCards: [],
|
||||
sections: []
|
||||
}
|
||||
@@ -731,6 +737,20 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
items: normalizedCompletionItems
|
||||
})
|
||||
}
|
||||
if (normalizedMaterialPrompts.length) {
|
||||
sections.push({
|
||||
kind: 'material',
|
||||
title: '材料补充提示',
|
||||
items: normalizedMaterialPrompts
|
||||
})
|
||||
}
|
||||
if (normalizedProfileAdviceItems.length) {
|
||||
sections.push({
|
||||
kind: 'profile',
|
||||
title: '历史操作建议',
|
||||
items: normalizedProfileAdviceItems
|
||||
})
|
||||
}
|
||||
if (normalizedRiskCards.length) {
|
||||
sections.push({
|
||||
kind: 'risk',
|
||||
@@ -742,10 +762,12 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待核对',
|
||||
badge: hasHighRisk ? '优先整改' : normalizedRiskCards.length ? '待核对' : '建议关注',
|
||||
summary: normalizedRiskCards.length
|
||||
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
|
||||
: '建议先补齐必填信息,完成后即可提交审批。',
|
||||
? `自动检测发现 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
|
||||
: normalizedMaterialPrompts.length
|
||||
? `自动检测发现 ${normalizedMaterialPrompts.length} 条材料补充提示,不作为风险计数。`
|
||||
: '结合历史操作记录生成提交建议,请按提示核对后提交审批。',
|
||||
items: normalizedCompletionItems,
|
||||
riskCards: normalizedRiskCards,
|
||||
sections
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
export function isAiPreReviewFlag(flag) {
|
||||
if (!flag || typeof flag !== 'object') {
|
||||
return false
|
||||
}
|
||||
const source = String(flag.source || '').trim()
|
||||
const eventType = String(flag.event_type || flag.eventType || '').trim()
|
||||
return source === 'ai_pre_review' || eventType === 'expense_claim_ai_pre_review'
|
||||
}
|
||||
|
||||
export function findLatestAiPreReviewEvent(flags = []) {
|
||||
return flags
|
||||
.filter(isAiPreReviewFlag)
|
||||
.map((flag) => ({
|
||||
...flag,
|
||||
eventTime: new Date(flag.created_at || flag.createdAt || 0).getTime()
|
||||
}))
|
||||
.sort((left, right) => (left.eventTime || 0) - (right.eventTime || 0))
|
||||
.pop() || null
|
||||
}
|
||||
|
||||
export function buildAiPreReviewSnapshot(payload, fallbackClaimId = '') {
|
||||
return {
|
||||
claimId: String(payload?.id || fallbackClaimId || '').trim(),
|
||||
riskFlags: Array.isArray(payload?.risk_flags_json) ? payload.risk_flags_json : []
|
||||
}
|
||||
}
|
||||
|
||||
export function isAiPreReviewPassed(event, requiresAiPreReview) {
|
||||
if (!requiresAiPreReview) {
|
||||
return true
|
||||
}
|
||||
return Boolean(event?.passed) || String(event?.status || '').trim() === 'passed'
|
||||
}
|
||||
|
||||
export function resolveSubmitActionLabel({
|
||||
isApplicationDocument,
|
||||
hasAiPreReviewResult,
|
||||
submitBusy
|
||||
}) {
|
||||
if (isApplicationDocument) {
|
||||
return submitBusy ? '提交中' : '提交审批'
|
||||
}
|
||||
if (!hasAiPreReviewResult) {
|
||||
return submitBusy ? '审核中' : 'AI审核'
|
||||
}
|
||||
return submitBusy ? '提交中' : '下一步'
|
||||
}
|
||||
|
||||
export function resolveSubmitActionIcon({ isApplicationDocument, hasAiPreReviewResult }) {
|
||||
if (isApplicationDocument) {
|
||||
return 'mdi mdi-send-circle-outline'
|
||||
}
|
||||
return hasAiPreReviewResult ? 'mdi mdi-arrow-right-circle-outline' : 'mdi mdi-shield-check-outline'
|
||||
}
|
||||
|
||||
export function resolveSubmitConfirmDescription({ isApplicationDocument, aiPreReviewPassed }) {
|
||||
if (isApplicationDocument) {
|
||||
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
||||
}
|
||||
if (!aiPreReviewPassed) {
|
||||
return 'AI预审存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
|
||||
}
|
||||
return 'AI预审已完成,请确认费用明细、附件材料和风险说明均已核对无误。确认后将进入审批流程。'
|
||||
}
|
||||
|
||||
export function resolveSubmitConfirmText(isApplicationDocument) {
|
||||
return isApplicationDocument ? '确认提交' : '确认下一步'
|
||||
}
|
||||
|
||||
export function resolveAiPreReviewToast(event) {
|
||||
return event && (event.passed || event.status === 'passed')
|
||||
? 'AI预审通过,请点击下一步提交审批。'
|
||||
: 'AI预审发现重大风险,请核对 AI建议 后再点击下一步。'
|
||||
}
|
||||
30
web/src/views/scripts/travelRequestDetailSubmitModel.js
Normal file
30
web/src/views/scripts/travelRequestDetailSubmitModel.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export function resolveSubmitActionLabel({
|
||||
isApplicationDocument,
|
||||
submitBusy
|
||||
}) {
|
||||
if (isApplicationDocument) {
|
||||
return submitBusy ? '提交中' : '提交审批'
|
||||
}
|
||||
return submitBusy ? '提交中' : '提交审批'
|
||||
}
|
||||
|
||||
export function resolveSubmitActionIcon({ isApplicationDocument }) {
|
||||
if (isApplicationDocument) {
|
||||
return 'mdi mdi-send-circle-outline'
|
||||
}
|
||||
return 'mdi mdi-send-circle-outline'
|
||||
}
|
||||
|
||||
export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHighRiskWarnings }) {
|
||||
if (isApplicationDocument) {
|
||||
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
|
||||
}
|
||||
if (hasHighRiskWarnings) {
|
||||
return '系统自动检测存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
|
||||
}
|
||||
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
|
||||
}
|
||||
|
||||
export function resolveSubmitConfirmText() {
|
||||
return '确认提交'
|
||||
}
|
||||
@@ -44,6 +44,7 @@ const FLOW_DURATION_SECOND_FIELDS = [
|
||||
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
|
||||
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
|
||||
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
|
||||
const FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS = 3000
|
||||
|
||||
function normalizeDurationValue(value, unit = 'ms') {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
@@ -598,7 +599,7 @@ export function useTravelReimbursementFlow({
|
||||
}
|
||||
|
||||
startFlowStep('pre-submit-review', {
|
||||
title: 'AI预审与风险识别',
|
||||
title: '自动检测与风险识别',
|
||||
tool: 'ExpenseClaimService.submit_claim',
|
||||
detail: '正在校验财务规则、风险规则和审批路径...'
|
||||
})
|
||||
@@ -665,6 +666,14 @@ export function useTravelReimbursementFlow({
|
||||
tool: config.tool,
|
||||
detail: config.detail
|
||||
})
|
||||
|
||||
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewAction)) {
|
||||
startFlowStep('draft-risk-review', {
|
||||
title: '草稿风险识别',
|
||||
tool: 'RuleEngine',
|
||||
detail: '正在校验申请单关联、票据完整性、金额口径和行程一致性...'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function isApplicationSessionActive() {
|
||||
@@ -685,6 +694,15 @@ export function useTravelReimbursementFlow({
|
||||
)
|
||||
}
|
||||
|
||||
function isDuplicateApplicationPayload(payload) {
|
||||
if (!isApplicationSessionActive()) {
|
||||
return false
|
||||
}
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const answer = String(result.answer || result.message || '').trim()
|
||||
return answer.includes('已存在申请单') && answer.includes('系统没有重复创建')
|
||||
}
|
||||
|
||||
function buildApplicationSubmitSuccessDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
@@ -697,6 +715,55 @@ export function useTravelReimbursementFlow({
|
||||
: `申请单提交成功,当前节点:${approvalStage}`
|
||||
}
|
||||
|
||||
function buildApplicationDuplicateDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const answer = String(result.answer || result.message || '').trim()
|
||||
const claimNo = answer.match(/AP-\d{14}-[A-HJ-NP-Z2-9]{8}/)?.[0] || ''
|
||||
return claimNo
|
||||
? `已拦截重复申请,已有申请单:${claimNo}`
|
||||
: '已拦截重复申请,未创建新申请单'
|
||||
}
|
||||
|
||||
function isSavedReimbursementDraftPayload(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: payload?.draft_payload && typeof payload.draft_payload === 'object'
|
||||
? payload.draft_payload
|
||||
: null
|
||||
return Boolean(
|
||||
draftPayload
|
||||
&& String(draftPayload.status || '').trim() === 'draft'
|
||||
&& String(draftPayload.draft_type || '').trim() !== 'expense_application'
|
||||
)
|
||||
}
|
||||
|
||||
function summarizeDraftRiskReviewDetail(payload) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const reviewPayload = result.review_payload && typeof result.review_payload === 'object'
|
||||
? result.review_payload
|
||||
: {}
|
||||
const riskCount = Array.isArray(reviewPayload.risk_briefs)
|
||||
? reviewPayload.risk_briefs.length
|
||||
: Array.isArray(result.risk_flags)
|
||||
? result.risk_flags.length
|
||||
: 0
|
||||
const missingCount = Array.isArray(reviewPayload.missing_slots)
|
||||
? reviewPayload.missing_slots.length
|
||||
: 0
|
||||
const issueParts = []
|
||||
if (riskCount) {
|
||||
issueParts.push(`${riskCount} 条风险/异常提醒`)
|
||||
}
|
||||
if (missingCount) {
|
||||
issueParts.push(`${missingCount} 项待补充信息`)
|
||||
}
|
||||
if (issueParts.length) {
|
||||
return `已完成草稿规则校验,识别到 ${issueParts.join('、')},可进入详情核对后继续提交。`
|
||||
}
|
||||
return '已完成草稿规则校验,暂未发现明确风险;可继续上传票据或进入详情核对。'
|
||||
}
|
||||
|
||||
function shouldHideToolCall(toolCall) {
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
@@ -750,9 +817,10 @@ export function useTravelReimbursementFlow({
|
||||
response.submission_blocked ||
|
||||
String(response.status || '').trim() === 'submitted' ||
|
||||
responseMessage.includes('AI预审') ||
|
||||
responseMessage.includes('自动检测') ||
|
||||
responseMessage.includes('审批')
|
||||
) {
|
||||
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
||||
return { key: 'pre-submit-review', title: '自动检测与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
||||
}
|
||||
if (responseMessage.includes('关联')) {
|
||||
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
@@ -782,7 +850,7 @@ export function useTravelReimbursementFlow({
|
||||
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
}
|
||||
if (response.submission_blocked) {
|
||||
return summarizeVisibleToolText(response.message) || 'AI预审发现待补充项,暂未提交审批'
|
||||
return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'
|
||||
}
|
||||
return (
|
||||
summarizeVisibleToolText(response.message || response.summary || response.result_summary)
|
||||
@@ -861,6 +929,30 @@ export function useTravelReimbursementFlow({
|
||||
if (!answer && !payload?.result) {
|
||||
return
|
||||
}
|
||||
if (isSubmittedApplicationPayload(payload)) {
|
||||
completePendingFlowStep(
|
||||
'application-submit-success',
|
||||
buildApplicationSubmitSuccessDetail(payload),
|
||||
null,
|
||||
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
|
||||
)
|
||||
}
|
||||
if (isDuplicateApplicationPayload(payload)) {
|
||||
completePendingFlowStep(
|
||||
'application-submit-success',
|
||||
buildApplicationDuplicateDetail(payload),
|
||||
null,
|
||||
{ title: '重复申请已拦截', tool: 'ApplicationSubmit' }
|
||||
)
|
||||
}
|
||||
if (isSavedReimbursementDraftPayload(payload)) {
|
||||
completePendingFlowStep(
|
||||
'draft-risk-review',
|
||||
summarizeDraftRiskReviewDetail(payload),
|
||||
null,
|
||||
{ title: '草稿风险识别', tool: 'RuleEngine' }
|
||||
)
|
||||
}
|
||||
const sceneSelectionPending = isExpenseSceneSelectionResult(payload)
|
||||
flowSteps.value
|
||||
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
||||
@@ -871,14 +963,6 @@ export function useTravelReimbursementFlow({
|
||||
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
|
||||
completeFlowStep(step.key, detail)
|
||||
})
|
||||
if (isSubmittedApplicationPayload(payload)) {
|
||||
completePendingFlowStep(
|
||||
'application-submit-success',
|
||||
buildApplicationSubmitSuccessDetail(payload),
|
||||
null,
|
||||
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
|
||||
)
|
||||
}
|
||||
const runFinishedAt = resolveFinishedTimestamp(run)
|
||||
flowFinishedAt.value = flowSteps.value.some(
|
||||
(step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)
|
||||
@@ -893,7 +977,15 @@ export function useTravelReimbursementFlow({
|
||||
}
|
||||
flowRefreshBusy.value = true
|
||||
try {
|
||||
const run = await fetchAgentRunDetail(flowRunId.value)
|
||||
const run = await Promise.race([
|
||||
fetchAgentRunDetail(flowRunId.value),
|
||||
new Promise((resolve) => {
|
||||
globalThis.setTimeout(() => resolve(null), FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS)
|
||||
})
|
||||
])
|
||||
if (!run) {
|
||||
return null
|
||||
}
|
||||
mergeFlowRunDetail(run)
|
||||
return run
|
||||
} catch (error) {
|
||||
|
||||
@@ -228,7 +228,7 @@ export function useTravelReimbursementGuidedFlow({
|
||||
|
||||
function pushReimbursementSummary() {
|
||||
pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), {
|
||||
meta: ['待生成核对信息'],
|
||||
meta: ['待生成报销草稿'],
|
||||
suggestedActions: buildGuidedReviewConfirmationActions()
|
||||
})
|
||||
}
|
||||
@@ -286,6 +286,10 @@ export function useTravelReimbursementGuidedFlow({
|
||||
const expenseTypeLabel = normalizeText(current.values.pending_scene_expense_type_label)
|
||||
const applicationNo = normalizeText(current.values.application_claim_no)
|
||||
const applicationId = normalizeText(current.values.application_claim_id)
|
||||
const applicationReason = normalizeText(current.values.application_reason)
|
||||
const applicationLocation = normalizeText(current.values.application_location)
|
||||
const applicationAmount = normalizeText(current.values.application_amount || current.values.application_amount_label)
|
||||
const applicationBusinessTime = normalizeText(current.values.application_business_time)
|
||||
if (!originalMessage || !expenseTypeLabel || !applicationNo) {
|
||||
return null
|
||||
}
|
||||
@@ -299,11 +303,12 @@ export function useTravelReimbursementGuidedFlow({
|
||||
return {
|
||||
rawText,
|
||||
userText: `关联申请单 ${applicationNo}`,
|
||||
pendingText: `已关联申请单,正在按${expenseTypeLabel}识别...`,
|
||||
pendingText: `已关联申请单,正在生成${expenseTypeLabel}草稿...`,
|
||||
systemGenerated: true,
|
||||
skipUserMessage: true,
|
||||
extraContext: {
|
||||
draft_claim_id: '',
|
||||
review_action: 'save_draft',
|
||||
user_input_text: originalMessage,
|
||||
expense_scene_selection: {
|
||||
expense_type: current.expenseType || 'other',
|
||||
@@ -314,11 +319,21 @@ export function useTravelReimbursementGuidedFlow({
|
||||
},
|
||||
review_form_values: {
|
||||
expense_type: expenseTypeLabel,
|
||||
reimbursement_type: expenseTypeLabel,
|
||||
reason: applicationReason,
|
||||
reason_value: applicationReason,
|
||||
location: applicationLocation,
|
||||
business_location: applicationLocation,
|
||||
time_range: applicationBusinessTime,
|
||||
business_time: applicationBusinessTime,
|
||||
amount: applicationAmount,
|
||||
application_claim_id: applicationId,
|
||||
application_claim_no: applicationNo,
|
||||
application_reason: current.values.application_reason || '',
|
||||
application_location: current.values.application_location || '',
|
||||
application_amount: current.values.application_amount || ''
|
||||
application_reason: applicationReason,
|
||||
application_location: applicationLocation,
|
||||
application_amount: current.values.application_amount || '',
|
||||
application_amount_label: current.values.application_amount_label || '',
|
||||
application_business_time: applicationBusinessTime
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,6 +344,21 @@ export function useTravelReimbursementGuidedFlow({
|
||||
const currentStep = getCurrentGuidedStep(currentState)
|
||||
const fileNames = buildFileNames(files)
|
||||
|
||||
if (isGuidedReimbursementReadyForReview(currentState) && fileNames.length) {
|
||||
const mergedFiles = mergePendingFiles(guidedPendingFiles.value, files)
|
||||
guidedPendingFiles.value = mergedFiles
|
||||
const submitOptions = {
|
||||
...buildGuidedReviewSubmitOptions(currentState, mergedFiles),
|
||||
skipDraftAssociationPrompt: true,
|
||||
skipUserMessage: true,
|
||||
pendingText: '已关联申请单,正在识别票据并生成报销草稿...'
|
||||
}
|
||||
resetGuidedFlowState()
|
||||
persistAndScroll()
|
||||
await submitExistingComposer(submitOptions)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentState.stepKey === 'expense_type') {
|
||||
const expenseType = resolveGuidedExpenseTypeFromText(answerText)
|
||||
if (!expenseType) {
|
||||
@@ -343,7 +373,7 @@ export function useTravelReimbursementGuidedFlow({
|
||||
}
|
||||
|
||||
if (currentState.stepKey === 'application_selection') {
|
||||
pushAssistant('请先点击上方列出的申请单完成关联。关联后,我再继续询问报销依据。', {
|
||||
pushAssistant('请先点击上方列出的申请单完成关联。关联后,我会直接进入生成报销草稿。', {
|
||||
meta: ['等待关联申请单'],
|
||||
suggestedActions: buildRequiredApplicationActions(
|
||||
currentState.applicationCandidates,
|
||||
@@ -521,6 +551,11 @@ export function useTravelReimbursementGuidedFlow({
|
||||
await submitExistingComposer(pendingSceneSubmitOptions)
|
||||
return true
|
||||
}
|
||||
if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) {
|
||||
pushReimbursementSummary()
|
||||
persistAndScroll()
|
||||
return true
|
||||
}
|
||||
pushNextReimbursementPrompt()
|
||||
persistAndScroll()
|
||||
return true
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||||
import {
|
||||
applyApplicationBusinessTimeContext,
|
||||
applyApplicationPolicyEstimateError,
|
||||
applyApplicationPolicyEstimateResult,
|
||||
buildApplicationPolicyEstimateRequest,
|
||||
@@ -58,6 +59,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
currentInsight,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
emitDraftSaved,
|
||||
emitOperationCompleted,
|
||||
emitRequestUpdated,
|
||||
extractReviewAttachmentNames,
|
||||
@@ -139,6 +141,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function emitSavedDraftRefresh(draftPayload) {
|
||||
if (!emitDraftSaved || isKnowledgeSession.value || !draftPayload?.claim_no) {
|
||||
return
|
||||
}
|
||||
const draftType = String(draftPayload.draft_type || '').trim()
|
||||
emitDraftSaved({
|
||||
claimId: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
|
||||
claimNo: String(draftPayload.claim_no || draftPayload.claimNo || '').trim(),
|
||||
status: String(draftPayload.status || '').trim(),
|
||||
approvalStage: String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim(),
|
||||
documentType: draftType === 'expense_application' ? 'application' : 'reimbursement'
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeRecognizedAttachmentData(data) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null
|
||||
@@ -351,9 +367,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return currentUser.value || user
|
||||
}
|
||||
|
||||
async function buildApplicationPreviewWithModelReview(rawText) {
|
||||
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null) {
|
||||
const user = await resolveApplicationPreviewUser()
|
||||
const localPreview = buildLocalApplicationPreview(rawText, user)
|
||||
const localPreview = applyApplicationBusinessTimeContext(
|
||||
buildLocalApplicationPreview(rawText, user),
|
||||
businessTimeContext
|
||||
)
|
||||
|
||||
const enrichWithPolicyEstimate = async (preview) => {
|
||||
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
|
||||
@@ -393,11 +412,14 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
)
|
||||
|
||||
const refinedPreview = buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
ontology,
|
||||
rawText,
|
||||
user
|
||||
const refinedPreview = applyApplicationBusinessTimeContext(
|
||||
buildModelRefinedApplicationPreview(
|
||||
localPreview,
|
||||
ontology,
|
||||
rawText,
|
||||
user
|
||||
),
|
||||
businessTimeContext
|
||||
)
|
||||
return {
|
||||
applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
|
||||
@@ -462,6 +484,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const feedbackOperationType = String(options.feedbackOperationType || '').trim()
|
||||
const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'
|
||||
const attachmentAssociationConfirmed = Boolean(
|
||||
options.associationConfirmed ||
|
||||
extraContext.attachment_association_confirmed ||
|
||||
@@ -499,7 +522,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||
|
||||
if (shouldUseBudgetCompileReport(rawText, { sessionType: activeSessionType.value }) && !reviewAction) {
|
||||
if (shouldUseBudgetCompileReport(rawText, {
|
||||
sessionType: activeSessionType.value,
|
||||
entrySource: props.entrySource,
|
||||
budgetContext: props.initialBudgetContext
|
||||
}) && !reviewAction) {
|
||||
return handleBudgetCompileReportSubmit({
|
||||
adjustComposerTextareaHeight,
|
||||
clearAttachedFiles,
|
||||
@@ -518,6 +545,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
rawText,
|
||||
replaceMessage,
|
||||
resetFlowRun,
|
||||
refreshCurrentUserFromBackend,
|
||||
budgetContext: props.initialBudgetContext,
|
||||
scrollToBottom,
|
||||
startFlowStep,
|
||||
submitting,
|
||||
@@ -595,7 +624,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText)
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText, selectedBusinessTimeContext)
|
||||
const reviewStatus = String(meta?.[1] || '').trim()
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
@@ -725,7 +754,13 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
} else {
|
||||
clearFlowSimulationTimers()
|
||||
}
|
||||
if (rawText && !reviewAction) {
|
||||
if (isApplicationSubmitOperation) {
|
||||
startFlowStep('application-submit-success', {
|
||||
title: '申请单提交成功',
|
||||
tool: 'ApplicationSubmit',
|
||||
detail: '正在提交费用申请...'
|
||||
})
|
||||
} else if (rawText && !reviewAction) {
|
||||
startFlowStep('intent', '正在识别业务意图...')
|
||||
if (waitForExpenseIntentConfirmation) {
|
||||
startExpenseIntentConfirmationFlowPreview(rawText)
|
||||
@@ -947,10 +982,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
extraContext.review_action = 'create_new_claim_from_documents'
|
||||
}
|
||||
|
||||
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||||
attachmentCount: effectiveFileNames.length,
|
||||
waitForSceneSelection: waitForExpenseSceneSelection
|
||||
})
|
||||
if (!isApplicationSubmitOperation) {
|
||||
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||||
attachmentCount: effectiveFileNames.length,
|
||||
waitForSceneSelection: waitForExpenseSceneSelection
|
||||
})
|
||||
}
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const orchestratorOptions = isKnowledgeSession.value
|
||||
@@ -977,9 +1014,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
department: user.department || user.departmentName || '',
|
||||
department_name: user.department || user.departmentName || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
employee_position: user.position || user.employeePosition || user.employee_position || '',
|
||||
employeePosition: user.position || user.employeePosition || user.employee_position || '',
|
||||
grade: user.grade || user.employeeGrade || user.employee_grade || '',
|
||||
employee_grade: user.grade || user.employeeGrade || user.employee_grade || '',
|
||||
employeeGrade: user.grade || user.employeeGrade || user.employee_grade || '',
|
||||
employee_no: user.employeeNo || user.employee_no || '',
|
||||
employeeNo: user.employeeNo || user.employee_no || '',
|
||||
manager_name: user.managerName || user.manager_name || '',
|
||||
managerName: user.managerName || user.manager_name || '',
|
||||
direct_manager_name: user.managerName || user.manager_name || user.directManagerName || user.direct_manager_name || '',
|
||||
directManagerName: user.managerName || user.manager_name || user.directManagerName || user.direct_manager_name || '',
|
||||
employee_location: user.location || '',
|
||||
cost_center: user.costCenter || user.cost_center || '',
|
||||
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
|
||||
@@ -1051,6 +1096,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
currentInsight.value = nextInsight
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewActionResult)) {
|
||||
emitSavedDraftRefresh(payload?.result?.draft_payload || null)
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
|
||||
Reference in New Issue
Block a user