完整修改内容: - 拆分 TravelReimbursementCreateView:提取审核面板纯模型、消息操作、建议动作处理、生命周期 watcher/UI 映射、小财管家运行时、续办流程和运行时文本模型,减少主组件继续堆叠业务分支。 - 调整申请预览链路:新增本地申请意图 gate,完善复杂差旅申请的大模型复核判断、交通方式缺失/候选识别、规则中心交通费用预估合并和申请冲突处理。 - 优化小财管家流程:抽出 steward typewriter 分段策略,避免 Markdown 表格逐字闪烁;补齐跨助手 carry、字段补齐续办、文本确认提交和行程规划推荐动作。 - 调整消息与样式:移除申请预览日期 chip 样式,收敛申请卡片/报销草稿消息的展示与复制、朗读、反馈入口逻辑。 - 更新测试:将源码锚点迁移到新模块,覆盖申请预览、提交确认、小财管家续办、引导流和审核抽屉相关断言。 验证: - node --check web/src/views/scripts/TravelReimbursementCreateView.js 及新增拆分模块 - npm --prefix web run build - node --test web/tests/expense-application-fast-preview.test.mjs web/tests/expense-application-submit-rich-confirm.test.mjs web/tests/travel-reimbursement-guided-flow.test.mjs 说明: - 后端/规则/容器配置/Audit 页面等工作区已有改动未纳入本提交。 - 容器内后端定向 pytest 曾运行 timeout 180s /tmp/x-financial-server-venv/bin/pytest -q <相关后端测试>,180 秒超时且超时前已有失败标记,未作为通过项记录。 - TravelReimbursementCreateView 当前仍超过 800 行,后续仍需继续拆分;本提交先把新增职责模块控制在 800 行内,阻止主类/主模块继续膨胀。
2209 lines
85 KiB
JavaScript
2209 lines
85 KiB
JavaScript
import {
|
||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||
buildAttachmentAssociationConfirmationMessage,
|
||
buildUnsavedDraftAttachmentConfirmationMessage
|
||
} from './travelReimbursementAttachmentModel.js'
|
||
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
|
||
import {
|
||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||
applyApplicationBusinessTimeContext,
|
||
applyApplicationPolicyEstimateError,
|
||
applyApplicationPolicyEstimateResult,
|
||
buildApplicationPolicyEstimateRequest,
|
||
buildLocalApplicationPreview,
|
||
buildLocalApplicationPreviewMessage,
|
||
buildModelRefinedApplicationPreview,
|
||
applicationDateRangesOverlap,
|
||
normalizeApplicationPreview,
|
||
normalizeTransportModeOption,
|
||
resolveApplicationDateRange,
|
||
shouldRequireApplicationModelReview,
|
||
shouldUseLocalApplicationPreview
|
||
} from '../../utils/expenseApplicationPreview.js'
|
||
import { fetchOntologyParse } from '../../services/ontology.js'
|
||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
|
||
import { fetchStewardSlotDecision } from '../../services/steward.js'
|
||
import {
|
||
handleBudgetCompileReportSubmit,
|
||
shouldUseBudgetCompileReport
|
||
} from './budgetAssistantReportModel.js'
|
||
import { resolveStewardTypewriterNextIndex } from './stewardTypewriter.js'
|
||
|
||
const STEWARD_ASSISTANT_NAME = '小财管家'
|
||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
|
||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
|
||
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
|
||
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||
|
||
const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
|
||
applicationType: 'expense_type',
|
||
time: 'time_range',
|
||
location: 'location',
|
||
reason: 'reason',
|
||
amount: 'amount',
|
||
transportMode: 'transport_mode',
|
||
transportEstimatedAmount: 'transport_estimated_amount',
|
||
trainEstimatedAmount: 'train_estimated_amount',
|
||
flightEstimatedAmount: 'flight_estimated_amount',
|
||
hotelAmount: 'hotel_amount',
|
||
allowanceAmount: 'allowance_amount',
|
||
policyTotalAmount: 'policy_total_amount',
|
||
reimbursementAmount: 'reimbursement_amount',
|
||
department: 'department_name',
|
||
applicant: 'employee_name',
|
||
grade: 'employee_grade'
|
||
}
|
||
|
||
const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = {
|
||
费用类型: 'expense_type',
|
||
申请类型: 'expense_type',
|
||
发生时间: 'time_range',
|
||
出发时间: 'time_range',
|
||
申请时间: 'time_range',
|
||
地点: 'location',
|
||
事由: 'reason',
|
||
金额: 'amount',
|
||
系统预估费用: 'amount',
|
||
出行方式: 'transport_mode',
|
||
附件: 'attachments',
|
||
'附件/凭证': 'attachments',
|
||
商户: 'merchant_name',
|
||
'商户/开票方': 'merchant_name',
|
||
客户: 'customer_name',
|
||
客户或项目对象: 'customer_name'
|
||
}
|
||
|
||
const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
|
||
expense_type: 'applicationType',
|
||
time_range: 'time',
|
||
location: 'location',
|
||
reason: 'reason',
|
||
amount: 'amount',
|
||
transport_mode: 'transportMode',
|
||
transport_estimated_amount: 'transportEstimatedAmount',
|
||
train_estimated_amount: 'trainEstimatedAmount',
|
||
flight_estimated_amount: 'flightEstimatedAmount',
|
||
hotel_amount: 'hotelAmount',
|
||
allowance_amount: 'allowanceAmount',
|
||
policy_total_amount: 'policyTotalAmount',
|
||
reimbursement_amount: 'reimbursementAmount',
|
||
department_name: 'department',
|
||
employee_name: 'applicant',
|
||
employee_grade: 'grade'
|
||
}
|
||
|
||
const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
|
||
expense_type: '费用类型',
|
||
time_range: '时间',
|
||
location: '地点',
|
||
reason: '事由',
|
||
amount: '金额',
|
||
transport_mode: '出行方式',
|
||
transport_estimated_amount: '交通费用预估',
|
||
train_estimated_amount: '火车费用预估',
|
||
flight_estimated_amount: '飞机费用预估',
|
||
hotel_amount: '住宿测算金额',
|
||
allowance_amount: '出差补贴金额',
|
||
policy_total_amount: '规则测算合计',
|
||
reimbursement_amount: '实际报销金额',
|
||
attachments: '附件/凭证',
|
||
customer_name: '客户或项目对象',
|
||
merchant_name: '商户/开票方',
|
||
department_name: '所属部门',
|
||
employee_name: '申请人',
|
||
employee_grade: '职级'
|
||
}
|
||
|
||
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
|
||
'amount',
|
||
'transport_estimated_amount',
|
||
'train_estimated_amount',
|
||
'flight_estimated_amount',
|
||
'hotel_amount',
|
||
'allowance_amount',
|
||
'policy_total_amount',
|
||
'reimbursement_amount',
|
||
'attachments',
|
||
'employee_no',
|
||
'department_name',
|
||
'employee_name'
|
||
])
|
||
|
||
const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([
|
||
'cancelled',
|
||
'canceled',
|
||
'void',
|
||
'voided',
|
||
'deleted',
|
||
'已取消',
|
||
'已作废',
|
||
'作废',
|
||
'已删除'
|
||
])
|
||
|
||
function normalizeClaimListPayload(payload) {
|
||
if (Array.isArray(payload)) {
|
||
return payload
|
||
}
|
||
return Array.isArray(payload?.items) ? payload.items : []
|
||
}
|
||
|
||
function normalizeClaimRiskFlags(claim) {
|
||
const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || []
|
||
if (Array.isArray(flags)) {
|
||
return flags
|
||
}
|
||
return flags && typeof flags === 'object' ? [flags] : []
|
||
}
|
||
|
||
function extractApplicationDetailFromClaim(claim) {
|
||
return normalizeClaimRiskFlags(claim).reduce((found, item) => {
|
||
if (found || !item || typeof item !== 'object') {
|
||
return found
|
||
}
|
||
const detail = item.application_detail || item.applicationDetail
|
||
return detail && typeof detail === 'object' ? detail : null
|
||
}, null)
|
||
}
|
||
|
||
function isApplicationClaimRecord(claim) {
|
||
const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase()
|
||
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
|
||
return (
|
||
expenseType === 'application' ||
|
||
expenseType === 'expense_application' ||
|
||
expenseType.endsWith('_application') ||
|
||
claimNo.startsWith('AP-') ||
|
||
claimNo.startsWith('APP-') ||
|
||
Boolean(extractApplicationDetailFromClaim(claim))
|
||
)
|
||
}
|
||
|
||
function normalizeApplicationExpenseType(value) {
|
||
const text = String(value || '').trim().toLowerCase()
|
||
if (!text) {
|
||
return ''
|
||
}
|
||
if (text === 'travel_application' || /差旅|出差/.test(text)) {
|
||
return 'travel_application'
|
||
}
|
||
if (text === 'purchase_application' || /采购/.test(text)) {
|
||
return 'purchase_application'
|
||
}
|
||
if (text === 'meeting_application' || /会务|会议/.test(text)) {
|
||
return 'meeting_application'
|
||
}
|
||
if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) {
|
||
return text === 'application' ? 'expense_application' : text
|
||
}
|
||
return 'expense_application'
|
||
}
|
||
|
||
function resolveClaimApplicationExpenseType(claim) {
|
||
const detail = extractApplicationDetailFromClaim(claim) || {}
|
||
return normalizeApplicationExpenseType(
|
||
claim?.expense_type ||
|
||
claim?.expenseType ||
|
||
detail.application_type ||
|
||
detail.applicationType ||
|
||
''
|
||
)
|
||
}
|
||
|
||
function isIgnoredApplicationDuplicateStatus(status) {
|
||
return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase())
|
||
}
|
||
|
||
function resolveClaimApplicationDateRange(claim) {
|
||
const detail = extractApplicationDetailFromClaim(claim) || {}
|
||
return (
|
||
resolveApplicationDateRange(
|
||
detail.time ||
|
||
detail.time_range ||
|
||
detail.timeRange ||
|
||
detail.application_time ||
|
||
detail.applicationTime ||
|
||
detail.application_business_time ||
|
||
detail.applicationBusinessTime ||
|
||
detail.application_date ||
|
||
detail.applicationDate,
|
||
detail.days || detail.application_days || detail.applicationDays
|
||
) ||
|
||
resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '')
|
||
)
|
||
}
|
||
|
||
function formatApplicationDateRangeLabel(range) {
|
||
if (!range?.startDate) {
|
||
return '待确认'
|
||
}
|
||
return range.startDate === range.endDate ? range.startDate : `${range.startDate} 至 ${range.endDate}`
|
||
}
|
||
|
||
function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
|
||
const preview = normalizeApplicationPreview(applicationPreview)
|
||
const fields = preview.fields || {}
|
||
const currentRange = resolveApplicationDateRange(fields.time, fields.days)
|
||
if (!currentRange) {
|
||
return null
|
||
}
|
||
const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType)
|
||
const claims = normalizeClaimListPayload(claimsPayload)
|
||
for (const claim of claims) {
|
||
if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) {
|
||
continue
|
||
}
|
||
const existingExpenseType = resolveClaimApplicationExpenseType(claim)
|
||
if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) {
|
||
continue
|
||
}
|
||
const existingRange = resolveClaimApplicationDateRange(claim)
|
||
if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) {
|
||
continue
|
||
}
|
||
return {
|
||
claim,
|
||
currentRange,
|
||
existingRange,
|
||
claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(),
|
||
claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(),
|
||
status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(),
|
||
reason: String(claim?.reason || '').trim(),
|
||
location: String(claim?.location || '').trim()
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
function buildApplicationDateConflictMessage(conflict) {
|
||
const claimNo = conflict?.claimNo || '已有申请'
|
||
return [
|
||
'我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
|
||
'',
|
||
'已有申请:',
|
||
`- **单号**:${claimNo}`,
|
||
`- **申请时间**:${formatApplicationDateRangeLabel(conflict?.existingRange)}`,
|
||
conflict?.location ? `- **地点**:${conflict.location}` : '',
|
||
conflict?.reason ? `- **事由**:${conflict.reason}` : '',
|
||
`- **当前节点**:${conflict?.status || '处理中'}`,
|
||
'',
|
||
`本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`,
|
||
'',
|
||
'请先查看已有申请,或修改本次出差时间后再继续。'
|
||
].filter(Boolean).join('\n')
|
||
}
|
||
|
||
function buildApplicationDateConflictActions(conflict) {
|
||
const actions = []
|
||
if (conflict?.claimId) {
|
||
actions.push({
|
||
action_type: 'open_application_detail',
|
||
label: '查看已有申请',
|
||
description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。',
|
||
icon: 'mdi mdi-file-search-outline',
|
||
payload: {
|
||
claim_id: conflict.claimId,
|
||
claim_no: conflict.claimNo
|
||
}
|
||
})
|
||
}
|
||
actions.push({
|
||
action_type: 'prefill_composer',
|
||
label: '修改出差时间',
|
||
description: '在输入框中补充新的出差日期后继续。',
|
||
icon: 'mdi mdi-calendar-edit-outline',
|
||
payload: {
|
||
prompt_prefill: '修改出差时间为:'
|
||
}
|
||
})
|
||
return actions
|
||
}
|
||
|
||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||
const {
|
||
MAX_ATTACHMENTS,
|
||
activeReviewPayload,
|
||
activeSessionType,
|
||
adjustComposerTextareaHeight,
|
||
attachedFiles,
|
||
buildAgentInsight,
|
||
buildClientTimeContext,
|
||
buildComposerBusinessTimeContext,
|
||
buildComposerFilePreviews,
|
||
buildDraftAssociationQueryPayload,
|
||
buildErrorInsight,
|
||
buildExpenseIntentConfirmationActions,
|
||
buildExpenseIntentConfirmationMessage,
|
||
buildExpenseSceneSelectionActions,
|
||
buildExpenseSceneSelectionMessage,
|
||
buildMessageMeta,
|
||
buildOcrDocumentsFromReviewPayload,
|
||
buildOcrFilePreviews,
|
||
buildOcrSummary,
|
||
buildOcrSummaryFromDocuments,
|
||
buildReviewFormContextFromPayload,
|
||
clearAttachedFiles,
|
||
clearFlowSimulationTimers,
|
||
completeFlowResult,
|
||
completeFlowStep,
|
||
composerBusinessTimeDraftTouched,
|
||
composerBusinessTimeTags,
|
||
composerDraft,
|
||
composerUploadIntent,
|
||
conversationId,
|
||
createMessage,
|
||
currentInsight,
|
||
currentUser,
|
||
draftClaimId,
|
||
emitDraftSaved,
|
||
emitOperationCompleted,
|
||
emitRequestUpdated,
|
||
extractReviewAttachmentNames,
|
||
failCurrentFlowStep,
|
||
fetchExpenseClaims,
|
||
fileInputRef,
|
||
flowRunId,
|
||
insightPanelCollapsed,
|
||
isKnowledgeSession,
|
||
linkedRequest,
|
||
mergeBusinessTimeIntoExtraContext,
|
||
mergeFilePreviews,
|
||
mergeFilesWithLimit,
|
||
mergeUploadAttachmentNames,
|
||
mergeUploadOcrDocuments,
|
||
messages,
|
||
nextTick,
|
||
normalizeExpenseQueryPayload,
|
||
normalizeOcrDocuments,
|
||
persistSessionState,
|
||
props,
|
||
recognizeOcrFiles,
|
||
refreshCurrentUserFromBackend,
|
||
refreshFlowRunDetail,
|
||
rememberFilePreviews,
|
||
replaceMessage,
|
||
resolveComposerDisplaySubmitText,
|
||
resetFlowRun,
|
||
resolveComposerSubmitText,
|
||
reviewInlineForm,
|
||
runOrchestrator,
|
||
scrollToBottom,
|
||
sessionSwitchBusy,
|
||
shouldRequestExpenseIntentConfirmation,
|
||
shouldRequestExpenseSceneSelection,
|
||
startExpenseClaimDraftFlowStep,
|
||
startExpenseIntentConfirmationFlowPreview,
|
||
startExpenseSceneSelectionFlowPreview,
|
||
startFlowStep,
|
||
startSemanticFlowPreview,
|
||
submitting,
|
||
syncComposerFilesToDraft,
|
||
toast
|
||
} = ctx
|
||
|
||
const pendingAttachmentAssociations = new Map()
|
||
|
||
function isStewardDelegatedRun(options = {}) {
|
||
return Boolean(options?.stewardContinuation && typeof options.stewardContinuation === 'object')
|
||
}
|
||
|
||
function resolveStewardDelegatedActionLabel(sessionType = '') {
|
||
return String(sessionType || '').trim() === 'application'
|
||
? '申请单核对'
|
||
: '报销单核对'
|
||
}
|
||
|
||
function buildStewardDelegatedPlan(continuation = null, thinkingEvents = [], streamStatus = 'streaming') {
|
||
return {
|
||
planId: String(continuation?.planId || continuation?.plan_id || 'steward_delegation').trim(),
|
||
planStatus: 'delegating',
|
||
summary: '',
|
||
visibleThinkingEventCount: Number.MAX_SAFE_INTEGER,
|
||
initialSummaryOnly: true,
|
||
thinkingEvents,
|
||
tasks: [],
|
||
attachmentGroups: [],
|
||
confirmationGroups: [],
|
||
streamStatus
|
||
}
|
||
}
|
||
|
||
function resolveApplicationPreviewMissingFieldsForSteward(preview = {}) {
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||
}
|
||
|
||
function isBlockingApplicationOntologyField(key = '') {
|
||
const normalizedKey = String(key || '').trim()
|
||
return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey))
|
||
}
|
||
|
||
function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) {
|
||
return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => {
|
||
const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || ''
|
||
return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey)
|
||
})
|
||
}
|
||
|
||
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
|
||
if (!missingFields.includes('出行方式')) {
|
||
return []
|
||
}
|
||
const iconMap = {
|
||
火车: 'mdi mdi-train',
|
||
飞机: 'mdi mdi-airplane',
|
||
轮船: 'mdi mdi-ferry'
|
||
}
|
||
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
|
||
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||
label: mode,
|
||
description: `选择${mode}后,由小财管家继续查询票价并测算费用。`,
|
||
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
|
||
payload: {
|
||
field_key: 'transportMode',
|
||
field_label: '出行方式',
|
||
value: mode,
|
||
applicationPreview: normalized,
|
||
steward_delegated_field_completion: true
|
||
}
|
||
}))
|
||
}
|
||
|
||
function resolveStewardContinuationCurrentTask(continuation = null) {
|
||
const task = continuation?.currentTask || continuation?.current_task || null
|
||
return task && typeof task === 'object' ? task : null
|
||
}
|
||
|
||
function normalizeCanonicalFieldList(fields = []) {
|
||
const normalized = []
|
||
if (!Array.isArray(fields)) {
|
||
return normalized
|
||
}
|
||
fields.forEach((field) => {
|
||
const key = String(field || '').trim()
|
||
if (key && !normalized.includes(key)) {
|
||
normalized.push(key)
|
||
}
|
||
})
|
||
return normalized
|
||
}
|
||
|
||
function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) {
|
||
const normalizedPreview = normalizeApplicationPreview(preview)
|
||
const previewFields = normalizedPreview.fields || {}
|
||
const task = resolveStewardContinuationCurrentTask(continuation)
|
||
const taskFields = task?.ontology_fields || task?.ontologyFields || {}
|
||
const fields = {}
|
||
Object.entries(taskFields || {}).forEach(([key, value]) => {
|
||
const normalizedKey = String(key || '').trim()
|
||
const normalizedValue = String(value || '').trim()
|
||
if (normalizedKey && normalizedValue) {
|
||
fields[normalizedKey] = normalizedValue
|
||
}
|
||
})
|
||
Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => {
|
||
const value = String(previewFields[previewKey] || '').trim()
|
||
if (value && value !== '待补充' && !fields[ontologyKey]) {
|
||
fields[ontologyKey] = value
|
||
}
|
||
})
|
||
return fields
|
||
}
|
||
|
||
function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) {
|
||
const task = resolveStewardContinuationCurrentTask(continuation)
|
||
const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || [])
|
||
.filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim())
|
||
if (taskMissingFields.length) {
|
||
return taskMissingFields
|
||
}
|
||
return resolveApplicationPreviewMissingFieldsForSteward(preview)
|
||
.map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '')
|
||
.filter((key, index, list) =>
|
||
key &&
|
||
isBlockingApplicationOntologyField(key) &&
|
||
!String(ontologyFields[key] || '').trim() &&
|
||
list.indexOf(key) === index
|
||
)
|
||
}
|
||
|
||
async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) {
|
||
const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation)
|
||
const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields)
|
||
try {
|
||
return await fetchStewardSlotDecision({
|
||
task_type: 'expense_application',
|
||
user_message: String(rawText || '').trim(),
|
||
ontology_fields: ontologyFields,
|
||
missing_fields: missingFields,
|
||
task_context: {
|
||
steward_continuation: continuation || null,
|
||
application_preview: normalizeApplicationPreview(preview)
|
||
}
|
||
}, {
|
||
timeoutMs: 45000,
|
||
timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。'
|
||
})
|
||
} catch (error) {
|
||
console.warn('Steward slot decision failed:', error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
function formatStewardDecisionUserText(text = '') {
|
||
let formatted = String(text || '').trim()
|
||
Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => {
|
||
const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
formatted = formatted
|
||
.replace(new RegExp(`(\\s*${escapedKey}\\s*)`, 'g'), '')
|
||
.replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '')
|
||
.replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label)
|
||
})
|
||
return formatted.replace(/\s{2,}/g, ' ').trim()
|
||
}
|
||
|
||
function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') {
|
||
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
|
||
return fallbackText
|
||
}
|
||
const question = formatStewardDecisionUserText(decision.question || '')
|
||
const rationale = formatStewardDecisionUserText(decision.rationale || '')
|
||
const parts = [
|
||
'我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。',
|
||
'',
|
||
rationale ? `**原因是:${rationale}**` : '',
|
||
'',
|
||
question || buildStewardApplicationPreviewMessage(preview, fallbackText)
|
||
].filter((item) => item !== '')
|
||
return parts.join('\n')
|
||
}
|
||
|
||
function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) {
|
||
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
|
||
return []
|
||
}
|
||
const normalizedPreview = normalizeApplicationPreview(preview)
|
||
const iconMap = {
|
||
火车: 'mdi mdi-train',
|
||
飞机: 'mdi mdi-airplane',
|
||
轮船: 'mdi mdi-ferry'
|
||
}
|
||
const actions = Array.isArray(decision.options) ? decision.options : []
|
||
return actions.map((option) => {
|
||
const canonicalField = String(option?.field_key || option?.fieldKey || '').trim()
|
||
if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) {
|
||
return null
|
||
}
|
||
const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField
|
||
const value = String(option?.value || option?.label || '').trim()
|
||
const label = String(option?.label || value).trim()
|
||
const normalizedValue = fieldKey === 'transportMode'
|
||
? normalizeTransportModeOption(value || label, '')
|
||
: value
|
||
if (!fieldKey || !value || !label) {
|
||
return null
|
||
}
|
||
if (fieldKey === 'transportMode' && !normalizedValue) {
|
||
return null
|
||
}
|
||
return {
|
||
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||
label,
|
||
description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`,
|
||
icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select',
|
||
payload: {
|
||
field_key: fieldKey,
|
||
field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label,
|
||
value: normalizedValue,
|
||
applicationPreview: normalizedPreview,
|
||
steward_delegated_field_completion: true
|
||
}
|
||
}
|
||
}).filter(Boolean)
|
||
}
|
||
|
||
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
|
||
if (!missingFields.length) {
|
||
return fallbackText
|
||
}
|
||
|
||
return [
|
||
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
|
||
'',
|
||
`**还需要你补充:${missingFields.join('、')}。**`,
|
||
'',
|
||
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
|
||
].join('\n')
|
||
}
|
||
|
||
function shouldPauseStewardApplicationPreview(preview = {}) {
|
||
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
|
||
}
|
||
|
||
function extractStewardCarryLine(text = '', label = '') {
|
||
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u'))
|
||
return match ? match[1].trim() : ''
|
||
}
|
||
|
||
function extractStewardDelegatedTaskTitle(text = '', sessionType = '') {
|
||
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u)
|
||
if (taskMatch?.[1]) {
|
||
return taskMatch[1].trim()
|
||
}
|
||
return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销'
|
||
}
|
||
|
||
function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') {
|
||
const text = String(summary || '').trim()
|
||
if (String(sessionType || '').trim() !== 'application') {
|
||
return text
|
||
}
|
||
return text
|
||
.replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充')
|
||
.replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充')
|
||
.replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式')
|
||
.replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式')
|
||
.replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,,、;;\s]*/g, '')
|
||
.replace(/[,,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '')
|
||
.replace(/[,,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '')
|
||
.replace(/([,,、;;。])\1+/g, '$1')
|
||
.replace(/[,,、;;\s]+。/g, '。')
|
||
.replace(/[,,、;;\s]+$/g, '')
|
||
.trim()
|
||
}
|
||
|
||
function summarizeApplicationPreviewForSteward(preview = {}) {
|
||
const normalized = normalizeApplicationPreview(preview)
|
||
const fields = normalized.fields || {}
|
||
return [
|
||
fields.time ? `时间:${fields.time}` : '',
|
||
fields.location ? `地点:${fields.location}` : '',
|
||
fields.reason ? `事由:${fields.reason}` : '',
|
||
fields.applicationType ? `类型:${fields.applicationType}` : ''
|
||
].filter(Boolean).join(';')
|
||
}
|
||
|
||
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
|
||
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
|
||
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||
const rawText = String(context.rawText || '').trim()
|
||
const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType)
|
||
const taskSummary = sanitizeStewardDelegatedTaskSummary(
|
||
extractStewardCarryLine(rawText, '任务摘要'),
|
||
sessionType
|
||
)
|
||
const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview)
|
||
|| extractStewardCarryLine(rawText, '已识别信息')
|
||
const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充')
|
||
const applicationMissingFields = context.applicationPreview
|
||
? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview)
|
||
: []
|
||
const missingInfo = applicationMissingFields.length
|
||
? applicationMissingFields.join('、')
|
||
: carryMissingInfo
|
||
const events = [
|
||
{
|
||
eventId: `${eventPrefix}-intent`,
|
||
title: '理解当前任务',
|
||
content: taskSummary
|
||
? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。`
|
||
: `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
|
||
},
|
||
{
|
||
eventId: `${eventPrefix}-known`,
|
||
title: '核对已知信息',
|
||
content: identifiedInfo
|
||
? `当前已识别到:${identifiedInfo}。`
|
||
: `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。`
|
||
}
|
||
]
|
||
if (missingInfo) {
|
||
events.push({
|
||
eventId: `${eventPrefix}-gap`,
|
||
title: '判断待补充信息',
|
||
content: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
|
||
})
|
||
} else {
|
||
events.push({
|
||
eventId: `${eventPrefix}-ready`,
|
||
title: '判断下一步动作',
|
||
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
|
||
})
|
||
}
|
||
return events
|
||
}
|
||
|
||
function resolveStewardDelegatedFinalMeta(finalExtras = {}) {
|
||
const sourceMeta = Array.isArray(finalExtras.meta) ? finalExtras.meta : []
|
||
const sourceLabel = sourceMeta.find((item) =>
|
||
String(item || '').trim() && String(item || '').trim() !== STEWARD_ASSISTANT_NAME
|
||
)
|
||
const requiresConfirmation = Boolean(
|
||
finalExtras.applicationPreview ||
|
||
finalExtras.reviewPayload ||
|
||
(Array.isArray(finalExtras.suggestedActions) && finalExtras.suggestedActions.length)
|
||
)
|
||
return [
|
||
STEWARD_ASSISTANT_NAME,
|
||
requiresConfirmation ? '等待用户确认' : '已完成',
|
||
sourceLabel || ''
|
||
].filter(Boolean).slice(0, 3)
|
||
}
|
||
|
||
function waitStewardDelegatedTick(intervalMs) {
|
||
return new Promise((resolve) => {
|
||
globalThis.setTimeout(resolve, intervalMs)
|
||
})
|
||
}
|
||
|
||
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
|
||
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
|
||
const pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions)
|
||
? finalExtras.suggestedActions
|
||
: []
|
||
const message = messages.value.find((item) => item.id === messageId)
|
||
if (!message) {
|
||
return
|
||
}
|
||
|
||
message.text = ''
|
||
message.assistantName = STEWARD_ASSISTANT_NAME
|
||
message.meta = [STEWARD_ASSISTANT_NAME, '思考中']
|
||
message.suggestedActions = []
|
||
message.stewardContinuation = continuation
|
||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [], 'streaming')
|
||
persistSessionState()
|
||
nextTick(scrollToBottom)
|
||
|
||
const typedEvents = []
|
||
const thinkingEvents = buildStewardDelegatedThinkingEvents(context.sessionType, continuation, context)
|
||
for (const eventData of thinkingEvents) {
|
||
const event = {
|
||
eventId: eventData.eventId,
|
||
stage: 'delegated_action',
|
||
title: eventData.title,
|
||
content: '',
|
||
status: 'running'
|
||
}
|
||
typedEvents.push(event)
|
||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||
persistSessionState()
|
||
nextTick(scrollToBottom)
|
||
|
||
const chars = Array.from(String(eventData.content || ''))
|
||
for (let index = 0; index < chars.length;) {
|
||
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
|
||
index = Math.min(chars.length, index + STEWARD_DELEGATED_THINKING_CHUNK_SIZE)
|
||
event.content = chars.slice(0, index).join('')
|
||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||
if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
|
||
nextTick(scrollToBottom)
|
||
}
|
||
}
|
||
event.content = String(eventData.content || '')
|
||
event.status = 'completed'
|
||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||
persistSessionState()
|
||
}
|
||
|
||
const text = String(finalText || '')
|
||
message.text = ''
|
||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||
message.suggestedActions = pendingSuggestedActions
|
||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||
const chars = Array.from(text)
|
||
for (let index = 0; index < chars.length;) {
|
||
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
|
||
index = resolveStewardTypewriterNextIndex(chars, index)
|
||
message.text = chars.slice(0, index).join('')
|
||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||
nextTick(scrollToBottom)
|
||
}
|
||
|
||
Object.assign(message, finalExtras, {
|
||
id: messageId,
|
||
text,
|
||
assistantName: STEWARD_ASSISTANT_NAME,
|
||
meta: resolveStewardDelegatedFinalMeta(finalExtras),
|
||
stewardContinuation: continuation,
|
||
stewardPlan: buildStewardDelegatedPlan(continuation, [...typedEvents], 'completed')
|
||
})
|
||
persistSessionState()
|
||
nextTick(scrollToBottom)
|
||
}
|
||
|
||
function resetStewardDelegatedInsightState() {
|
||
resetFlowRun({ startedAt: 0, openDrawer: false })
|
||
insightPanelCollapsed.value = true
|
||
currentInsight.value = {
|
||
intent: 'welcome',
|
||
agent: null
|
||
}
|
||
}
|
||
|
||
function isApplicationDraftPayload(draftPayload) {
|
||
return String(draftPayload?.draft_type || '').trim() === 'expense_application'
|
||
}
|
||
|
||
function isSubmittedApplicationDraftPayload(draftPayload) {
|
||
return (
|
||
isApplicationDraftPayload(draftPayload)
|
||
&& String(draftPayload?.status || '').trim() === 'submitted'
|
||
)
|
||
}
|
||
|
||
function shouldExposeReviewPayloadForMessage(payload, options = {}) {
|
||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||
if (options.isApplicationSubmitOperation || isApplicationDraftPayload(result.draft_payload)) {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function buildPresentationPayload(payload, { exposeReviewPayload = true } = {}) {
|
||
if (exposeReviewPayload) {
|
||
return payload
|
||
}
|
||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||
return {
|
||
...payload,
|
||
result: {
|
||
...result,
|
||
review_payload: null
|
||
}
|
||
}
|
||
}
|
||
|
||
function buildOperationFeedbackState(context) {
|
||
if (!context) {
|
||
return null
|
||
}
|
||
return {
|
||
context,
|
||
submitting: false,
|
||
submitted: false,
|
||
dismissed: false,
|
||
rating: 0,
|
||
reason: '',
|
||
error: ''
|
||
}
|
||
}
|
||
|
||
function resolveAssistantResultText(payload, fallbackAnswer) {
|
||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||
if (isSubmittedApplicationDraftPayload(result.draft_payload)) {
|
||
return ''
|
||
}
|
||
return result.answer || result.message || fallbackAnswer
|
||
}
|
||
|
||
function createPendingAttachmentAssociationId() {
|
||
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
|
||
}
|
||
const documents = Array.isArray(data.ocrDocuments) ? data.ocrDocuments : []
|
||
if (!documents.length) {
|
||
return null
|
||
}
|
||
return {
|
||
ocrPayload: data.ocrPayload || null,
|
||
ocrSummary: String(data.ocrSummary || '').trim(),
|
||
ocrDocuments: documents,
|
||
ocrFilePreviews: Array.isArray(data.ocrFilePreviews) ? data.ocrFilePreviews : []
|
||
}
|
||
}
|
||
|
||
function hasReceiptFolderSourceFile(files) {
|
||
return files.some((file) => String(file?.receiptId || '').trim())
|
||
}
|
||
|
||
async function promptUnlinkedReceiptFolderIfNeeded({
|
||
detailScopedClaimId,
|
||
files,
|
||
fileNames,
|
||
options,
|
||
rawText,
|
||
resolvedUploadDisposition,
|
||
reviewAction,
|
||
systemGenerated,
|
||
userText
|
||
}) {
|
||
if (
|
||
isKnowledgeSession.value ||
|
||
systemGenerated ||
|
||
!files.length ||
|
||
detailScopedClaimId ||
|
||
resolvedUploadDisposition ||
|
||
options.skipReceiptFolderUnlinkedPrompt ||
|
||
options.skipDraftAssociationPrompt ||
|
||
reviewAction ||
|
||
hasReceiptFolderSourceFile(files)
|
||
) {
|
||
return false
|
||
}
|
||
|
||
let unlinkedReceipts = []
|
||
try {
|
||
unlinkedReceipts = await fetchReceiptFolderItems('unlinked')
|
||
} catch (error) {
|
||
console.warn('Failed to load unlinked receipt folder items before attachment upload:', error)
|
||
return false
|
||
}
|
||
const count = Array.isArray(unlinkedReceipts) ? unlinkedReceipts.length : 0
|
||
if (!count) {
|
||
return false
|
||
}
|
||
|
||
resetFlowRun()
|
||
if (!options.skipUserMessage) {
|
||
messages.value.push(createMessage('user', userText, fileNames))
|
||
}
|
||
messages.value.push(createMessage(
|
||
'assistant',
|
||
`票据夹中还有 ${count} 份未关联票据。建议先处理这些票据再上传新附件,避免重复保存或遗漏关联。`,
|
||
[],
|
||
{
|
||
meta: ['票据夹待关联'],
|
||
suggestedActions: [
|
||
{
|
||
action_type: 'open_receipt_folder',
|
||
label: '去票据夹关联',
|
||
icon: 'mdi mdi-folder-open-outline',
|
||
payload: { target_view: 'receiptFolder' }
|
||
},
|
||
{
|
||
action_type: 'continue_upload_with_unlinked_receipts',
|
||
label: '继续上传新附件',
|
||
icon: 'mdi mdi-upload-outline',
|
||
payload: { raw_text: rawText }
|
||
}
|
||
]
|
||
}
|
||
))
|
||
nextTick(scrollToBottom)
|
||
persistSessionState()
|
||
return true
|
||
}
|
||
|
||
function buildConfirmedAssociationText(message) {
|
||
return String(message?.text || '')
|
||
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
|
||
.replace(`[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确定')
|
||
}
|
||
|
||
function resolveReviewPanelScope({
|
||
reviewPayload = null,
|
||
reviewAction = '',
|
||
fileCount = 0,
|
||
rawText = ''
|
||
} = {}) {
|
||
if (!reviewPayload || typeof reviewPayload !== 'object') {
|
||
return ''
|
||
}
|
||
|
||
const normalizedAction = String(reviewAction || '').trim()
|
||
const documentCount = Array.isArray(reviewPayload.document_cards) ? reviewPayload.document_cards.length : 0
|
||
const riskCount = Array.isArray(reviewPayload.risk_briefs) ? reviewPayload.risk_briefs.length : 0
|
||
const asksRisk = /风险|隐患|超标|异常|重复|待整改|风险项|高风险|中风险|低风险/.test(String(rawText || ''))
|
||
|
||
if (fileCount > 0 && documentCount > 0) {
|
||
return 'documents'
|
||
}
|
||
if (riskCount > 0 && (asksRisk || ['next_step', 'submit', 'submit_claim'].includes(normalizedAction))) {
|
||
return 'risk'
|
||
}
|
||
if (!normalizedAction && fileCount === 0) {
|
||
return 'overview'
|
||
}
|
||
return ''
|
||
}
|
||
|
||
async function confirmPendingAttachmentAssociation(message) {
|
||
if (submitting.value || sessionSwitchBusy.value) return null
|
||
|
||
const pending = message?.pendingAttachmentAssociation && typeof message.pendingAttachmentAssociation === 'object'
|
||
? message.pendingAttachmentAssociation
|
||
: null
|
||
const associationId = String(pending?.id || '').trim()
|
||
if (!associationId || pending?.status === 'confirmed') {
|
||
return null
|
||
}
|
||
|
||
const runtime = pendingAttachmentAssociations.get(associationId)
|
||
if (!runtime || !Array.isArray(runtime.files) || !runtime.files.length) {
|
||
toast('当前会话里没有可归集的附件原件,请重新上传票据后再确认。')
|
||
return null
|
||
}
|
||
|
||
pending.status = 'confirmed'
|
||
message.pendingAttachmentAssociation = pending
|
||
message.text = buildConfirmedAssociationText(message)
|
||
message.meta = ['已确认归集']
|
||
persistSessionState()
|
||
|
||
if (pending.mode === 'save_then_associate') {
|
||
const inheritedReviewContext = buildReviewFormContextFromPayload(
|
||
activeReviewPayload.value,
|
||
reviewInlineForm.value
|
||
)
|
||
const savePayload = await submitComposer({
|
||
rawText: '请先把当前已识别的报销信息保存为草稿,随后继续归集本次上传的附件。',
|
||
userText: '',
|
||
files: [],
|
||
skipUserMessage: true,
|
||
pendingText: '正在先保存未保存单据...',
|
||
systemGenerated: true,
|
||
extraContext: {
|
||
...runtime.extraContext,
|
||
...inheritedReviewContext,
|
||
review_action: 'save_draft'
|
||
}
|
||
})
|
||
const savedClaimId = String(savePayload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||
const savedClaimNo = String(savePayload?.result?.draft_payload?.claim_no || '').trim()
|
||
if (!savedClaimId) {
|
||
toast('当前单据还没有保存成功,请稍后重试。')
|
||
return savePayload
|
||
}
|
||
|
||
return submitComposer({
|
||
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${savedClaimNo || '当前草稿'}`,
|
||
userText: `保存草稿并归集 ${runtime.fileNames.length} 份票据`,
|
||
files: runtime.files,
|
||
uploadDisposition: 'continue_existing',
|
||
skipDraftAssociationPrompt: true,
|
||
skipUserMessage: true,
|
||
appendToCurrentFlow: true,
|
||
systemGenerated: true,
|
||
pendingText: savedClaimNo
|
||
? `草稿 ${savedClaimNo} 已保存,正在识别并归集附件...`
|
||
: '草稿已保存,正在识别并归集附件...',
|
||
associationConfirmed: true,
|
||
extraContext: {
|
||
...runtime.extraContext,
|
||
review_action: 'link_to_existing_draft',
|
||
draft_claim_id: savedClaimId,
|
||
selected_claim_id: savedClaimId,
|
||
selected_claim_no: savedClaimNo,
|
||
attachment_association_confirmed: true
|
||
}
|
||
})
|
||
}
|
||
|
||
return submitComposer({
|
||
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||
files: runtime.files,
|
||
uploadDisposition: 'continue_existing',
|
||
skipDraftAssociationPrompt: true,
|
||
pendingText: runtime.claimNo
|
||
? `正在将票据归集到草稿 ${runtime.claimNo}...`
|
||
: '正在将票据归集到当前草稿...',
|
||
associationConfirmed: true,
|
||
recognizedAttachmentData: {
|
||
ocrPayload: runtime.ocrPayload,
|
||
ocrSummary: runtime.ocrSummary,
|
||
ocrDocuments: runtime.ocrDocuments,
|
||
ocrFilePreviews: runtime.ocrFilePreviews
|
||
},
|
||
extraContext: {
|
||
...runtime.extraContext,
|
||
review_action: 'link_to_existing_draft',
|
||
draft_claim_id: runtime.claimId,
|
||
selected_claim_id: runtime.claimId,
|
||
selected_claim_no: runtime.claimNo,
|
||
attachment_association_confirmed: true
|
||
}
|
||
})
|
||
}
|
||
|
||
function buildBackendMessage(rawText, fileNames, ocrSummary = '', sessionTypeOverride = '') {
|
||
const parts = []
|
||
const normalizedText = String(rawText || '').trim()
|
||
const sessionType = String(sessionTypeOverride || activeSessionType.value || '').trim()
|
||
const isKnowledgeMessage = sessionType === 'knowledge'
|
||
|
||
if (normalizedText) {
|
||
parts.push(normalizedText)
|
||
} else if (fileNames.length) {
|
||
parts.push(
|
||
isKnowledgeMessage
|
||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称回答财务相关问题。`
|
||
: sessionType === 'application'
|
||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理费用申请建议和待核对信息。`
|
||
: sessionType === 'approval'
|
||
? `我上传了 ${fileNames.length} 份附件,请结合附件名称整理审核风险和处理建议。`
|
||
: `我上传了 ${fileNames.length} 份票据,请结合附件名称给出报销建议并整理待核对信息。`
|
||
)
|
||
}
|
||
|
||
if (fileNames.length) {
|
||
parts.push(`附件名称:${fileNames.join('、')}`)
|
||
}
|
||
|
||
if (ocrSummary) {
|
||
parts.push(`OCR摘要:${ocrSummary}`)
|
||
}
|
||
|
||
if (props.entrySource === 'detail' && linkedRequest.value?.id) {
|
||
parts.push(`关联单号:${linkedRequest.value.id}`)
|
||
}
|
||
|
||
return parts.join('\n')
|
||
}
|
||
|
||
function resolveDetailScopedClaimId() {
|
||
if (props.entrySource !== 'detail' || isKnowledgeSession.value) {
|
||
return ''
|
||
}
|
||
return String(
|
||
linkedRequest.value?.claimId ||
|
||
linkedRequest.value?.claim_id ||
|
||
''
|
||
).trim()
|
||
}
|
||
|
||
function buildApplicationPreviewReviewMeta(ontology) {
|
||
return [
|
||
'申请核对预览',
|
||
String(ontology?.parse_strategy || '').trim() === 'llm_primary'
|
||
? '模型复核完成'
|
||
: '规则兜底复核'
|
||
]
|
||
}
|
||
|
||
async function resolveApplicationPreviewUser() {
|
||
const user = currentUser.value || {}
|
||
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
|
||
return user
|
||
}
|
||
|
||
await refreshCurrentUserFromBackend({ silent: true })
|
||
return currentUser.value || user
|
||
}
|
||
|
||
async function buildApplicationPreviewWithModelReview(
|
||
rawText,
|
||
businessTimeContext = null,
|
||
sessionTypeOverride = '',
|
||
options = {}
|
||
) {
|
||
const user = await resolveApplicationPreviewUser()
|
||
const localPreview = applyApplicationBusinessTimeContext(
|
||
buildLocalApplicationPreview(rawText, user),
|
||
businessTimeContext
|
||
)
|
||
|
||
const enrichWithPolicyEstimate = async (preview) => {
|
||
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
|
||
if (!estimateRequest.canCalculate) {
|
||
return preview
|
||
}
|
||
try {
|
||
const result = await calculateTravelReimbursement(estimateRequest.payload)
|
||
return applyApplicationPolicyEstimateResult(preview, result, user)
|
||
} catch (error) {
|
||
console.warn('Application policy estimate failed:', error)
|
||
return applyApplicationPolicyEstimateError(preview, error, user)
|
||
}
|
||
}
|
||
|
||
const requireModelReview = shouldRequireApplicationModelReview(rawText)
|
||
if (options.skipModelReview && !requireModelReview) {
|
||
return {
|
||
applicationPreview: await enrichWithPolicyEstimate({
|
||
...localPreview,
|
||
modelReviewStatus: 'skipped'
|
||
}),
|
||
meta: ['申请核对预览', '结构化快路径']
|
||
}
|
||
}
|
||
|
||
try {
|
||
const ontology = await fetchOntologyParse(
|
||
{
|
||
query: rawText,
|
||
user_id: user.username || user.name || 'anonymous',
|
||
context_json: {
|
||
...buildExpenseApplicationOntologyContext(user),
|
||
session_type: String(sessionTypeOverride || activeSessionType.value || '').trim(),
|
||
entry_source: props.entrySource,
|
||
user_input_text: rawText
|
||
}
|
||
},
|
||
{
|
||
timeoutMs: 45000,
|
||
timeoutMessage: '模型抽取申请字段超时,已保留当前本地预览。'
|
||
}
|
||
)
|
||
|
||
const refinedPreview = applyApplicationBusinessTimeContext(
|
||
buildModelRefinedApplicationPreview(
|
||
localPreview,
|
||
ontology,
|
||
rawText,
|
||
user
|
||
),
|
||
businessTimeContext
|
||
)
|
||
return {
|
||
applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
|
||
meta: buildApplicationPreviewReviewMeta(ontology)
|
||
}
|
||
} catch (error) {
|
||
console.warn('Application preview model refinement failed:', error)
|
||
return {
|
||
applicationPreview: await enrichWithPolicyEstimate({
|
||
...localPreview,
|
||
modelReviewStatus: 'failed'
|
||
}),
|
||
meta: ['申请核对预览', '模型复核失败']
|
||
}
|
||
}
|
||
}
|
||
|
||
async function submitComposer(options = {}) {
|
||
if (submitting.value || sessionSwitchBusy.value) return null
|
||
|
||
const rawText = resolveComposerSubmitText(options.rawText).trim()
|
||
const systemGenerated = Boolean(options.systemGenerated)
|
||
const appendToCurrentFlow = Boolean(options.appendToCurrentFlow)
|
||
const effectiveSessionType = String(options.sessionTypeOverride || activeSessionType.value || '').trim()
|
||
const stewardDelegated = isStewardDelegatedRun(options)
|
||
const effectiveIsKnowledgeSession = effectiveSessionType === 'knowledge'
|
||
const normalizedFiles = effectiveIsKnowledgeSession ? [] : Array.from(options.files ?? attachedFiles.value)
|
||
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
|
||
const files = fileMergeResult.files
|
||
const detailScopedClaimId = resolveDetailScopedClaimId()
|
||
const detailScopedUpload = Boolean(detailScopedClaimId && files.length)
|
||
if (detailScopedClaimId) {
|
||
draftClaimId.value = detailScopedClaimId
|
||
}
|
||
const resolvedUploadDisposition =
|
||
String(options.uploadDisposition || '').trim() ||
|
||
(composerUploadIntent.value === 'continue_existing' ? 'continue_existing' : '') ||
|
||
(detailScopedUpload ? 'continue_existing' : '')
|
||
if (fileMergeResult.overflowCount > 0) {
|
||
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
|
||
}
|
||
if (!rawText && !files.length) return
|
||
const fileNames = files.map((file) => file.name)
|
||
|
||
const optionExtraContext = options.extraContext && typeof options.extraContext === 'object'
|
||
? { ...options.extraContext }
|
||
: {}
|
||
const detailScopedClaimNo = String(
|
||
linkedRequest.value?.documentNo ||
|
||
linkedRequest.value?.id ||
|
||
''
|
||
).trim()
|
||
const initialExtraContext = detailScopedClaimId
|
||
? {
|
||
...optionExtraContext,
|
||
draft_claim_id: detailScopedClaimId,
|
||
selected_claim_id: detailScopedClaimId,
|
||
selected_claim_no: detailScopedClaimNo,
|
||
detail_scope_claim_id: detailScopedClaimId
|
||
}
|
||
: optionExtraContext
|
||
const selectedBusinessTimeContext = effectiveIsKnowledgeSession ? null : buildComposerBusinessTimeContext()
|
||
const extraContext = effectiveIsKnowledgeSession
|
||
? initialExtraContext
|
||
: 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 ||
|
||
reviewAction === 'link_to_existing_draft' ||
|
||
detailScopedUpload
|
||
)
|
||
const hasSelectedExpenseType = Boolean(
|
||
extraContext.expense_scene_selection ||
|
||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
|
||
)
|
||
const hasConfirmedExpenseIntent = Boolean(extraContext.expense_intent_confirmed)
|
||
const waitForExpenseIntentConfirmation = !stewardDelegated && shouldRequestExpenseIntentConfirmation(rawText, {
|
||
sessionType: effectiveSessionType,
|
||
attachmentCount: files.length,
|
||
reviewAction,
|
||
hasSelectedExpenseType,
|
||
hasConfirmedExpenseIntent
|
||
})
|
||
const waitForExpenseSceneSelection = !stewardDelegated && !waitForExpenseIntentConfirmation && shouldRequestExpenseSceneSelection(rawText, {
|
||
sessionType: effectiveSessionType,
|
||
attachmentCount: files.length,
|
||
reviewAction,
|
||
hasSelectedExpenseType
|
||
})
|
||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||
const userText =
|
||
String(options.userText || '').trim() ||
|
||
resolveComposerDisplaySubmitText(rawText) ||
|
||
rawText ||
|
||
(effectiveIsKnowledgeSession
|
||
? `我上传了 ${fileNames.length} 份附件,请帮我回答相关财务问题。`
|
||
: resolvedUploadDisposition === 'continue_existing'
|
||
? `继续上传 ${fileNames.length} 份票据,并归集到当前单据。`
|
||
: resolvedUploadDisposition === 'new_document'
|
||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||
|
||
if (!stewardDelegated && shouldUseBudgetCompileReport(rawText, {
|
||
sessionType: effectiveSessionType,
|
||
entrySource: props.entrySource,
|
||
budgetContext: props.initialBudgetContext
|
||
}) && !reviewAction) {
|
||
return handleBudgetCompileReportSubmit({
|
||
adjustComposerTextareaHeight,
|
||
clearAttachedFiles,
|
||
completeFlowStep,
|
||
composerBusinessTimeDraftTouched,
|
||
composerBusinessTimeTags,
|
||
composerDraft,
|
||
createMessage,
|
||
currentUser,
|
||
fileInputRef,
|
||
fileNames,
|
||
messages,
|
||
nextTick,
|
||
options,
|
||
persistSessionState,
|
||
rawText,
|
||
replaceMessage,
|
||
resetFlowRun,
|
||
refreshCurrentUserFromBackend,
|
||
budgetContext: props.initialBudgetContext,
|
||
scrollToBottom,
|
||
startFlowStep,
|
||
submitting,
|
||
userText
|
||
})
|
||
}
|
||
|
||
const scopeGuard = resolveAssistantScopeGuard(rawText, effectiveSessionType, {
|
||
attachmentCount: files.length,
|
||
hasActiveReviewPayload: Boolean(activeReviewPayload.value),
|
||
reviewAction
|
||
})
|
||
if (scopeGuard && !systemGenerated && !reviewAction && !options.skipScopeGuard) {
|
||
if (!options.skipUserMessage) {
|
||
messages.value.push(createMessage('user', userText, fileNames))
|
||
}
|
||
messages.value.push(createMessage('assistant', scopeGuard.text, [], {
|
||
meta: scopeGuard.meta,
|
||
suggestedActions: scopeGuard.suggestedActions
|
||
}))
|
||
composerDraft.value = ''
|
||
composerBusinessTimeTags.value = []
|
||
composerBusinessTimeDraftTouched.value = false
|
||
nextTick(() => {
|
||
adjustComposerTextareaHeight()
|
||
scrollToBottom()
|
||
})
|
||
persistSessionState()
|
||
return null
|
||
}
|
||
|
||
if (shouldUseLocalApplicationPreview(rawText, {
|
||
sessionType: effectiveSessionType,
|
||
attachmentCount: files.length,
|
||
reviewAction,
|
||
systemGenerated
|
||
})) {
|
||
const intentStartedAt = Date.now()
|
||
const reviewStartedAt = intentStartedAt
|
||
if (stewardDelegated) {
|
||
resetStewardDelegatedInsightState()
|
||
} else {
|
||
resetFlowRun()
|
||
startFlowStep('intent', {
|
||
title: '业务意图识别',
|
||
tool: 'ontology.intent_detection',
|
||
detail: '正在识别是否为费用申请事项...'
|
||
})
|
||
startFlowStep('application-review-preview', {
|
||
title: '申请信息核对',
|
||
tool: 'ontology.application_review',
|
||
detail: '正在复核申请信息,并查询交通票价...'
|
||
})
|
||
}
|
||
if (!options.skipUserMessage) {
|
||
messages.value.push(createMessage('user', userText, fileNames))
|
||
}
|
||
const pendingMessage = createMessage(
|
||
'assistant',
|
||
stewardDelegated ? '' : '正在复核申请信息,并查询交通票价,请稍候。',
|
||
[],
|
||
stewardDelegated
|
||
? {
|
||
assistantName: STEWARD_ASSISTANT_NAME,
|
||
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
|
||
stewardContinuation: options.stewardContinuation || null,
|
||
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
|
||
}
|
||
: {
|
||
meta: ['模型复核中']
|
||
}
|
||
)
|
||
messages.value.push(pendingMessage)
|
||
composerDraft.value = ''
|
||
composerBusinessTimeTags.value = []
|
||
composerBusinessTimeDraftTouched.value = false
|
||
clearAttachedFiles()
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = ''
|
||
}
|
||
nextTick(() => {
|
||
adjustComposerTextareaHeight()
|
||
scrollToBottom()
|
||
})
|
||
persistSessionState()
|
||
|
||
submitting.value = true
|
||
try {
|
||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
||
rawText,
|
||
selectedBusinessTimeContext,
|
||
effectiveSessionType,
|
||
{
|
||
skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview)
|
||
}
|
||
)
|
||
const reviewStatus = String(meta?.[1] || '').trim()
|
||
let applicationDateConflict = null
|
||
try {
|
||
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
|
||
} catch (error) {
|
||
console.warn('Failed to check overlapping application dates:', error)
|
||
}
|
||
|
||
if (applicationDateConflict) {
|
||
const conflictText = buildApplicationDateConflictMessage(applicationDateConflict)
|
||
const conflictActions = buildApplicationDateConflictActions(applicationDateConflict)
|
||
if (!stewardDelegated) {
|
||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||
completeFlowStep(
|
||
'application-review-preview',
|
||
'检测到同日期已有申请,已停止重复创建',
|
||
Date.now() - reviewStartedAt
|
||
)
|
||
replaceMessage(pendingMessage.id, createMessage(
|
||
'assistant',
|
||
conflictText,
|
||
[],
|
||
{
|
||
meta: ['申请日期冲突'],
|
||
suggestedActions: conflictActions,
|
||
stewardContinuation: options.stewardContinuation || null
|
||
}
|
||
))
|
||
} else {
|
||
await typeStewardDelegatedMessage(
|
||
pendingMessage.id,
|
||
conflictText,
|
||
{
|
||
meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'],
|
||
suggestedActions: conflictActions,
|
||
stewardContinuation: options.stewardContinuation || null
|
||
},
|
||
{
|
||
sessionType: effectiveSessionType,
|
||
rawText,
|
||
applicationPreview,
|
||
stewardContinuation: options.stewardContinuation || null
|
||
}
|
||
)
|
||
}
|
||
persistSessionState()
|
||
nextTick(scrollToBottom)
|
||
return null
|
||
}
|
||
|
||
if (!stewardDelegated) {
|
||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||
completeFlowStep(
|
||
'application-review-preview',
|
||
reviewStatus === '模型复核完成'
|
||
? '模型复核完成,已生成申请核对表'
|
||
: reviewStatus === '模型复核失败'
|
||
? '模型复核失败,已生成临时核对表'
|
||
: '模型未返回稳定结果,已完成规则兜底核对',
|
||
Date.now() - reviewStartedAt
|
||
)
|
||
}
|
||
if (stewardDelegated) {
|
||
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
|
||
applicationPreview,
|
||
buildLocalApplicationPreviewMessage(applicationPreview)
|
||
)
|
||
const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview)
|
||
const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision
|
||
const slotDecision = shouldFetchSlotDecision
|
||
? await fetchStewardApplicationSlotDecision(
|
||
applicationPreview,
|
||
rawText,
|
||
options.stewardContinuation || null
|
||
)
|
||
: null
|
||
const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview)
|
||
const pauseForMissingFields = slotDecision
|
||
? String(slotDecision.next_action || '').trim() === 'ask_user'
|
||
: localPauseForMissingFields
|
||
const stewardApplicationText = buildStewardSlotDecisionMessage(
|
||
slotDecision,
|
||
applicationPreview,
|
||
fallbackStewardApplicationText
|
||
)
|
||
await typeStewardDelegatedMessage(
|
||
pendingMessage.id,
|
||
stewardApplicationText,
|
||
{
|
||
meta,
|
||
applicationPreview: pauseForMissingFields ? null : applicationPreview,
|
||
suggestedActions: slotDecisionActions.length
|
||
? slotDecisionActions
|
||
: buildStewardApplicationPreviewSuggestedActions(applicationPreview),
|
||
stewardContinuation: options.stewardContinuation || null
|
||
},
|
||
{
|
||
sessionType: effectiveSessionType,
|
||
rawText,
|
||
applicationPreview,
|
||
stewardContinuation: options.stewardContinuation || null
|
||
}
|
||
)
|
||
} else {
|
||
replaceMessage(pendingMessage.id, createMessage(
|
||
'assistant',
|
||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||
[],
|
||
{
|
||
meta,
|
||
applicationPreview,
|
||
stewardContinuation: options.stewardContinuation || null
|
||
}
|
||
))
|
||
}
|
||
persistSessionState()
|
||
nextTick(scrollToBottom)
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
return null
|
||
}
|
||
|
||
if (!stewardDelegated && await promptUnlinkedReceiptFolderIfNeeded({
|
||
detailScopedClaimId,
|
||
files,
|
||
fileNames,
|
||
options,
|
||
rawText,
|
||
resolvedUploadDisposition,
|
||
reviewAction,
|
||
systemGenerated,
|
||
userText
|
||
})) {
|
||
return null
|
||
}
|
||
|
||
const hasUnsavedReviewDraft = Boolean(
|
||
!stewardDelegated &&
|
||
!effectiveIsKnowledgeSession &&
|
||
files.length &&
|
||
activeReviewPayload.value &&
|
||
!String(draftClaimId.value || '').trim() &&
|
||
!detailScopedClaimId &&
|
||
!resolvedUploadDisposition &&
|
||
!options.skipDraftAssociationPrompt &&
|
||
!reviewAction
|
||
)
|
||
if (hasUnsavedReviewDraft) {
|
||
const associationId = createPendingAttachmentAssociationId()
|
||
pendingAttachmentAssociations.set(associationId, {
|
||
files,
|
||
fileNames,
|
||
filePreviews: buildComposerFilePreviews(files),
|
||
extraContext
|
||
})
|
||
resetFlowRun()
|
||
if (!options.skipUserMessage) {
|
||
messages.value.push(createMessage('user', userText, fileNames))
|
||
}
|
||
messages.value.push(createMessage(
|
||
'assistant',
|
||
buildUnsavedDraftAttachmentConfirmationMessage({ fileNames }),
|
||
[],
|
||
{
|
||
meta: ['等待确认保存并归集'],
|
||
pendingAttachmentAssociation: {
|
||
id: associationId,
|
||
mode: 'save_then_associate',
|
||
status: 'pending',
|
||
fileNames
|
||
}
|
||
}
|
||
))
|
||
nextTick(scrollToBottom)
|
||
persistSessionState()
|
||
return null
|
||
}
|
||
|
||
if (
|
||
!stewardDelegated &&
|
||
!effectiveIsKnowledgeSession &&
|
||
files.length &&
|
||
!resolvedUploadDisposition &&
|
||
!options.skipDraftAssociationPrompt &&
|
||
!reviewAction
|
||
) {
|
||
try {
|
||
const claims = await fetchExpenseClaims()
|
||
const queryPayload = buildDraftAssociationQueryPayload(claims)
|
||
if (queryPayload?.records?.length) {
|
||
resetFlowRun()
|
||
if (!options.skipUserMessage) {
|
||
messages.value.push(createMessage('user', userText, fileNames))
|
||
}
|
||
messages.value.push(createMessage(
|
||
'assistant',
|
||
`我找到 ${queryPayload.records.length} 张可关联的草稿/待补单据。请先选择这批附件要归集到哪张单据,我再开始识别附件。`,
|
||
[],
|
||
{
|
||
meta: ['等待选择关联单据'],
|
||
queryPayload
|
||
}
|
||
))
|
||
composerDraft.value = ''
|
||
composerBusinessTimeTags.value = []
|
||
composerBusinessTimeDraftTouched.value = false
|
||
nextTick(() => {
|
||
adjustComposerTextareaHeight()
|
||
scrollToBottom()
|
||
})
|
||
persistSessionState()
|
||
return null
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to load draft claims before attachment recognition:', error)
|
||
resetFlowRun()
|
||
if (!options.skipUserMessage) {
|
||
messages.value.push(createMessage('user', userText, fileNames))
|
||
}
|
||
messages.value.push(createMessage(
|
||
'assistant',
|
||
'我暂时没能查询到可关联的草稿/待补单据,所以先不识别这批附件。请稍后重试,或从对应草稿进入后继续上传票据。',
|
||
[],
|
||
{
|
||
meta: ['单据查询失败']
|
||
}
|
||
))
|
||
nextTick(scrollToBottom)
|
||
persistSessionState()
|
||
toast(error?.message || '查询可关联草稿失败,请稍后重试。')
|
||
return null
|
||
}
|
||
}
|
||
|
||
if (stewardDelegated) {
|
||
resetStewardDelegatedInsightState()
|
||
} else if (!appendToCurrentFlow) {
|
||
resetFlowRun()
|
||
} else {
|
||
clearFlowSimulationTimers()
|
||
}
|
||
if (!stewardDelegated && isApplicationSubmitOperation) {
|
||
startFlowStep('application-submit-success', {
|
||
title: '申请单提交成功',
|
||
tool: 'ApplicationSubmit',
|
||
detail: '正在提交费用申请...'
|
||
})
|
||
} else if (!stewardDelegated && rawText && !reviewAction) {
|
||
startFlowStep('intent', '正在识别业务意图...')
|
||
if (waitForExpenseIntentConfirmation) {
|
||
startExpenseIntentConfirmationFlowPreview(rawText)
|
||
} else if (waitForExpenseSceneSelection) {
|
||
startExpenseSceneSelectionFlowPreview(rawText)
|
||
} else {
|
||
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
|
||
}
|
||
}
|
||
|
||
const filePreviews = buildComposerFilePreviews(files)
|
||
rememberFilePreviews(filePreviews)
|
||
|
||
// 只有在非静默模式下才添加用户消息
|
||
if (!options.skipUserMessage) {
|
||
messages.value.push(createMessage('user', userText, fileNames))
|
||
}
|
||
|
||
if (waitForExpenseIntentConfirmation) {
|
||
messages.value.push(createMessage('assistant', buildExpenseIntentConfirmationMessage(rawText), [], {
|
||
meta: ['等待确认意图'],
|
||
suggestedActions: buildExpenseIntentConfirmationActions(rawText)
|
||
}))
|
||
composerDraft.value = ''
|
||
composerBusinessTimeTags.value = []
|
||
composerBusinessTimeDraftTouched.value = false
|
||
clearAttachedFiles()
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = ''
|
||
}
|
||
nextTick(() => {
|
||
adjustComposerTextareaHeight()
|
||
scrollToBottom()
|
||
})
|
||
return null
|
||
}
|
||
|
||
if (waitForExpenseSceneSelection) {
|
||
messages.value.push(createMessage('assistant', buildExpenseSceneSelectionMessage(rawText), [], {
|
||
meta: ['等待选择场景'],
|
||
suggestedActions: buildExpenseSceneSelectionActions(rawText)
|
||
}))
|
||
composerDraft.value = ''
|
||
composerBusinessTimeTags.value = []
|
||
composerBusinessTimeDraftTouched.value = false
|
||
clearAttachedFiles()
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = ''
|
||
}
|
||
nextTick(() => {
|
||
adjustComposerTextareaHeight()
|
||
scrollToBottom()
|
||
})
|
||
return null
|
||
}
|
||
|
||
const pendingMessage = createMessage(
|
||
'assistant',
|
||
stewardDelegated ? '' : options.pendingText || (
|
||
effectiveIsKnowledgeSession
|
||
? '正在整理财务知识答案...'
|
||
: effectiveSessionType === 'application'
|
||
? '正在识别申请信息并查询交通票价...'
|
||
: effectiveSessionType === 'approval'
|
||
? '正在查询审核上下文并整理风险提示...'
|
||
: '正在识别并整理右侧核对信息...'
|
||
),
|
||
[],
|
||
stewardDelegated
|
||
? {
|
||
assistantName: STEWARD_ASSISTANT_NAME,
|
||
meta: [STEWARD_ASSISTANT_NAME, '思考中'],
|
||
stewardContinuation: options.stewardContinuation || null,
|
||
stewardPlan: buildStewardDelegatedPlan(options.stewardContinuation || null, [], 'streaming')
|
||
}
|
||
: {
|
||
meta: ['处理中']
|
||
}
|
||
)
|
||
messages.value.push(pendingMessage)
|
||
|
||
composerDraft.value = ''
|
||
composerBusinessTimeTags.value = []
|
||
composerBusinessTimeDraftTouched.value = false
|
||
clearAttachedFiles()
|
||
if (fileInputRef.value) {
|
||
fileInputRef.value.value = ''
|
||
}
|
||
nextTick(adjustComposerTextareaHeight)
|
||
|
||
submitting.value = true
|
||
nextTick(scrollToBottom)
|
||
|
||
let responsePayload = null
|
||
|
||
try {
|
||
const user = currentUser.value || {}
|
||
let ocrPayload = null
|
||
let ocrSummary = ''
|
||
let ocrDocuments = []
|
||
let ocrFilePreviews = []
|
||
const recognizedAttachmentData = normalizeRecognizedAttachmentData(options.recognizedAttachmentData)
|
||
|
||
if (files.length) {
|
||
const ocrStartedAt = Date.now()
|
||
if (!stewardDelegated) {
|
||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||
}
|
||
if (recognizedAttachmentData) {
|
||
ocrPayload = recognizedAttachmentData.ocrPayload
|
||
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
|
||
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
|
||
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
|
||
rememberFilePreviews(ocrFilePreviews)
|
||
if (!stewardDelegated) {
|
||
completeFlowStep('ocr', `复用已确认的 ${ocrDocuments.length || files.length} 张票据识别结果`, Date.now() - ocrStartedAt)
|
||
}
|
||
} else {
|
||
try {
|
||
ocrPayload = await recognizeOcrFiles(files, {
|
||
timeoutMs: 90000,
|
||
timeoutMessage: '票据 OCR 识别超时,已继续使用附件名称处理。'
|
||
})
|
||
ocrSummary = buildOcrSummary(ocrPayload)
|
||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||
rememberFilePreviews(ocrFilePreviews)
|
||
if (!stewardDelegated) {
|
||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||
}
|
||
} catch (error) {
|
||
console.warn('OCR request failed:', error)
|
||
if (!stewardDelegated) {
|
||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!stewardDelegated && resolvedUploadDisposition === 'continue_existing') {
|
||
replaceMessage(pendingMessage.id, {
|
||
...pendingMessage,
|
||
text: attachmentAssociationConfirmed
|
||
? '票据识别已完成,正在把本次附件归集到已选择的草稿...'
|
||
: '票据识别已完成,正在整理归集前确认信息...',
|
||
meta: attachmentAssociationConfirmed ? ['正在归集'] : ['等待确认归集']
|
||
})
|
||
persistSessionState()
|
||
}
|
||
}
|
||
|
||
const associationTargetClaimId = String(extraContext.draft_claim_id || draftClaimId.value || '').trim()
|
||
const associationTargetClaimNo = String(
|
||
extraContext.selected_claim_no ||
|
||
extraContext.draft_claim_no ||
|
||
''
|
||
).trim()
|
||
if (
|
||
files.length &&
|
||
resolvedUploadDisposition === 'continue_existing' &&
|
||
associationTargetClaimId &&
|
||
!attachmentAssociationConfirmed
|
||
) {
|
||
const associationId = createPendingAttachmentAssociationId()
|
||
const pendingAssociation = {
|
||
id: associationId,
|
||
status: 'pending',
|
||
claimId: associationTargetClaimId,
|
||
claimNo: associationTargetClaimNo,
|
||
fileNames
|
||
}
|
||
pendingAttachmentAssociations.set(associationId, {
|
||
files,
|
||
fileNames,
|
||
ocrPayload,
|
||
ocrSummary,
|
||
ocrDocuments,
|
||
ocrFilePreviews,
|
||
filePreviews,
|
||
claimId: associationTargetClaimId,
|
||
claimNo: associationTargetClaimNo,
|
||
extraContext: {
|
||
...extraContext,
|
||
draft_claim_id: associationTargetClaimId,
|
||
selected_claim_id: associationTargetClaimId,
|
||
selected_claim_no: associationTargetClaimNo
|
||
}
|
||
})
|
||
replaceMessage(pendingMessage.id, createMessage(
|
||
'assistant',
|
||
buildAttachmentAssociationConfirmationMessage({
|
||
claimNo: associationTargetClaimNo,
|
||
fileNames,
|
||
ocrDocuments
|
||
}),
|
||
[],
|
||
{
|
||
meta: ['等待确认归集'],
|
||
pendingAttachmentAssociation: pendingAssociation
|
||
}
|
||
))
|
||
persistSessionState()
|
||
nextTick(scrollToBottom)
|
||
return null
|
||
}
|
||
|
||
let effectiveFileNames = [...fileNames]
|
||
let effectiveOcrDocuments = [...ocrDocuments]
|
||
let effectiveOcrSummary = ocrSummary
|
||
|
||
if (resolvedUploadDisposition === 'continue_existing') {
|
||
extraContext.review_action = 'link_to_existing_draft'
|
||
const inheritedReviewContext = buildReviewFormContextFromPayload(
|
||
activeReviewPayload.value,
|
||
reviewInlineForm.value
|
||
)
|
||
if (inheritedReviewContext.review_form_values) {
|
||
extraContext.review_form_values = {
|
||
...inheritedReviewContext.review_form_values,
|
||
...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
|
||
? extraContext.review_form_values
|
||
: {})
|
||
}
|
||
}
|
||
if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) {
|
||
extraContext.business_time_context = inheritedReviewContext.business_time_context
|
||
}
|
||
effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames)
|
||
effectiveOcrDocuments = mergeUploadOcrDocuments(
|
||
buildOcrDocumentsFromReviewPayload(activeReviewPayload.value),
|
||
ocrDocuments
|
||
)
|
||
effectiveOcrSummary = buildOcrSummaryFromDocuments(effectiveOcrDocuments)
|
||
} else if (resolvedUploadDisposition === 'new_document') {
|
||
extraContext.review_action = 'create_new_claim_from_documents'
|
||
}
|
||
|
||
if (!isApplicationSubmitOperation && !stewardDelegated) {
|
||
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||
attachmentCount: effectiveFileNames.length,
|
||
waitForSceneSelection: waitForExpenseSceneSelection
|
||
})
|
||
}
|
||
|
||
const backendMessage = buildBackendMessage(
|
||
rawText,
|
||
effectiveFileNames,
|
||
effectiveOcrSummary,
|
||
effectiveSessionType
|
||
)
|
||
const orchestratorOptions = effectiveIsKnowledgeSession
|
||
? {
|
||
timeoutMs: 75000,
|
||
timeoutMessage: '知识问答仍在检索整理,已停止等待。请稍后重试,或补充制度名称、费用类型等限定条件。'
|
||
}
|
||
: {
|
||
timeoutMs: 120000,
|
||
timeoutMessage: '票据归集处理超时,当前仍停留在原草稿,请稍后重试或重新选择附件。'
|
||
}
|
||
|
||
const payload = await runOrchestrator(
|
||
{
|
||
source: 'user_message',
|
||
user_id: user.username || user.name || 'anonymous',
|
||
conversation_id: conversationId.value || null,
|
||
message: backendMessage,
|
||
context_json: {
|
||
role_codes: Array.isArray(user.roleCodes) ? user.roleCodes : [],
|
||
is_admin: Boolean(user.isAdmin),
|
||
name: user.name || '',
|
||
role: user.role || '',
|
||
department: user.department || user.departmentName || '',
|
||
department_name: user.department || user.departmentName || '',
|
||
position: user.position || '',
|
||
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 || '',
|
||
employee_risk_profile: user.riskProfile && typeof user.riskProfile === 'object' ? user.riskProfile : {},
|
||
...buildClientTimeContext(),
|
||
session_type: effectiveSessionType,
|
||
entry_source: props.entrySource,
|
||
user_input_text: systemGenerated ? '' : rawText,
|
||
attachment_names: effectiveFileNames,
|
||
attachment_count: effectiveFileNames.length,
|
||
draft_claim_id: effectiveIsKnowledgeSession ? undefined : draftClaimId.value || undefined,
|
||
ocr_summary: effectiveOcrSummary,
|
||
ocr_documents: effectiveOcrDocuments,
|
||
...(linkedRequest.value && !effectiveIsKnowledgeSession ? { request_context: linkedRequest.value } : {}),
|
||
...extraContext
|
||
}
|
||
},
|
||
orchestratorOptions
|
||
)
|
||
responsePayload = payload
|
||
flowRunId.value = stewardDelegated ? '' : String(payload?.run_id || '').trim()
|
||
let flowRunDetail = null
|
||
if (flowRunId.value) {
|
||
flowRunDetail = await refreshFlowRunDetail()
|
||
}
|
||
|
||
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
|
||
draftClaimId.value =
|
||
effectiveIsKnowledgeSession
|
||
? ''
|
||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||
|
||
const reviewActionResult = String(extraContext.review_action || '').trim()
|
||
const resultClaimNo = String(payload?.result?.draft_payload?.claim_no || '').trim()
|
||
const fallbackAnswer = reviewActionResult === 'link_to_existing_draft'
|
||
? (resultClaimNo ? `已将本次上传的票据关联到草稿 ${resultClaimNo}。` : '已将本次上传的票据关联到现有草稿。')
|
||
: '智能体已完成处理。'
|
||
const operationFeedbackContext = String(payload?.status || '').trim() === 'succeeded'
|
||
? emitOperationCompleted?.(payload, {
|
||
operationType: feedbackOperationType || reviewActionResult || (files.length ? 'attachment_review' : 'assistant_round')
|
||
})
|
||
: null
|
||
const exposeReviewPayload = shouldExposeReviewPayloadForMessage(payload, { isApplicationSubmitOperation })
|
||
const presentationPayload = buildPresentationPayload(payload, { exposeReviewPayload })
|
||
const presentationResult = presentationPayload?.result && typeof presentationPayload.result === 'object'
|
||
? presentationPayload.result
|
||
: {}
|
||
const resultReviewPayload = presentationResult.review_payload || null
|
||
const resultSuggestedActions = exposeReviewPayload && Array.isArray(presentationResult.suggested_actions)
|
||
? presentationResult.suggested_actions
|
||
: []
|
||
const assistantMessage = createMessage('assistant', resolveAssistantResultText(presentationPayload, fallbackAnswer), [], {
|
||
meta: buildMessageMeta(presentationPayload, effectiveFileNames),
|
||
citations: Array.isArray(presentationResult.citations) ? presentationResult.citations : [],
|
||
suggestedActions: resultSuggestedActions,
|
||
queryPayload: normalizeExpenseQueryPayload(presentationResult.query_payload),
|
||
draftPayload: presentationResult.draft_payload || null,
|
||
reviewPayload: resultReviewPayload,
|
||
reviewPanelScope: stewardDelegated
|
||
? ''
|
||
: resolveReviewPanelScope({
|
||
reviewPayload: resultReviewPayload,
|
||
reviewAction: reviewActionResult,
|
||
fileCount: files.length,
|
||
rawText
|
||
}),
|
||
riskFlags: Array.isArray(presentationResult.risk_flags) ? presentationResult.risk_flags : [],
|
||
operationFeedback: buildOperationFeedbackState(operationFeedbackContext),
|
||
assistantName: stewardDelegated ? STEWARD_ASSISTANT_NAME : undefined,
|
||
stewardContinuation: options.stewardContinuation || null
|
||
})
|
||
if (stewardDelegated) {
|
||
await typeStewardDelegatedMessage(
|
||
pendingMessage.id,
|
||
assistantMessage.text,
|
||
{
|
||
...assistantMessage,
|
||
id: pendingMessage.id,
|
||
reviewPanelScope: ''
|
||
},
|
||
{
|
||
sessionType: effectiveSessionType,
|
||
rawText,
|
||
fileNames: effectiveFileNames,
|
||
stewardContinuation: options.stewardContinuation || null
|
||
}
|
||
)
|
||
resetStewardDelegatedInsightState()
|
||
} else {
|
||
replaceMessage(pendingMessage.id, assistantMessage)
|
||
const nextInsight = buildAgentInsight(
|
||
presentationPayload,
|
||
effectiveFileNames,
|
||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||
)
|
||
if (nextInsight.agent) {
|
||
nextInsight.agent.reviewPanelScope = assistantMessage.reviewPanelScope
|
||
}
|
||
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()
|
||
if (!effectiveIsKnowledgeSession && resolvedDraftClaimId && files.length) {
|
||
const persistComposerFilesToDraft = async () => {
|
||
try {
|
||
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||
persistSessionState()
|
||
if (detailScopedUpload) {
|
||
emitRequestUpdated?.({
|
||
claimId: resolvedDraftClaimId,
|
||
source: 'detail-smart-entry-attachment-sync',
|
||
uploadedCount: Number(syncResult?.uploadedCount || 0),
|
||
skippedCount: Number(syncResult?.skippedCount || 0)
|
||
})
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
||
}
|
||
}
|
||
const persistTask = persistComposerFilesToDraft()
|
||
if (detailScopedUpload) {
|
||
await persistTask
|
||
} else {
|
||
void persistTask
|
||
}
|
||
}
|
||
} catch (error) {
|
||
clearFlowSimulationTimers()
|
||
if (!stewardDelegated) {
|
||
failCurrentFlowStep(error)
|
||
}
|
||
replaceMessage(
|
||
pendingMessage.id,
|
||
createMessage(
|
||
'assistant',
|
||
error?.message || '无法连接后端 Orchestrator,请稍后重试。',
|
||
[],
|
||
stewardDelegated
|
||
? {
|
||
assistantName: STEWARD_ASSISTANT_NAME,
|
||
meta: [STEWARD_ASSISTANT_NAME, '调用失败'],
|
||
stewardContinuation: options.stewardContinuation || null
|
||
}
|
||
: {
|
||
meta: ['调用失败']
|
||
}
|
||
)
|
||
)
|
||
if (stewardDelegated) {
|
||
resetStewardDelegatedInsightState()
|
||
} else {
|
||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||
}
|
||
persistSessionState()
|
||
} finally {
|
||
submitting.value = false
|
||
composerUploadIntent.value = ''
|
||
nextTick(scrollToBottom)
|
||
}
|
||
|
||
return responsePayload
|
||
}
|
||
|
||
|
||
return {
|
||
confirmPendingAttachmentAssociationInternal: confirmPendingAttachmentAssociation,
|
||
submitComposerInternal: submitComposer
|
||
}
|
||
}
|