feat: 重构报销单服务并完善前端提交与审核交互
重构 expense_claims 服务模块结构并优化差旅票据审核逻辑, 增强用户代理服务的票据类型识别,前端报销创建页面拆分为 附件模型和会话模型模块,重构提交编排器和草稿关联确认流 程,更新知识库索引,补充单元测试。
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||
buildAttachmentAssociationConfirmationMessage
|
||||
} from './travelReimbursementAttachmentModel.js'
|
||||
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
MAX_ATTACHMENTS,
|
||||
@@ -74,6 +79,87 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
uploadDecisionDialogOpen,
|
||||
toast
|
||||
} = ctx
|
||||
|
||||
const pendingAttachmentAssociations = new Map()
|
||||
|
||||
function createPendingAttachmentAssociationId() {
|
||||
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
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 buildConfirmedAssociationText(message) {
|
||||
return String(message?.text || '').replace(
|
||||
`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`,
|
||||
'已确认'
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
return submitComposer({
|
||||
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||||
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||||
files: runtime.files,
|
||||
uploadDisposition: 'continue_existing',
|
||||
skipUploadDecisionPrompt: true,
|
||||
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 = '') {
|
||||
const parts = []
|
||||
const normalizedText = String(rawText || '').trim()
|
||||
@@ -128,6 +214,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? initialExtraContext
|
||||
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const attachmentAssociationConfirmed = Boolean(
|
||||
options.associationConfirmed ||
|
||||
extraContext.attachment_association_confirmed ||
|
||||
reviewAction === 'link_to_existing_draft'
|
||||
)
|
||||
const hasSelectedExpenseType = Boolean(
|
||||
extraContext.expense_scene_selection ||
|
||||
String(extraContext.review_form_values?.expense_type || extraContext.review_form_values?.reimbursement_type || '').trim()
|
||||
@@ -305,21 +396,100 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
let ocrSummary = ''
|
||||
let ocrDocuments = []
|
||||
let ocrFilePreviews = []
|
||||
const recognizedAttachmentData = normalizeRecognizedAttachmentData(options.recognizedAttachmentData)
|
||||
|
||||
if (files.length) {
|
||||
const ocrStartedAt = Date.now()
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files)
|
||||
ocrSummary = buildOcrSummary(ocrPayload)
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
if (recognizedAttachmentData) {
|
||||
ocrPayload = recognizedAttachmentData.ocrPayload
|
||||
ocrSummary = recognizedAttachmentData.ocrSummary || buildOcrSummaryFromDocuments(recognizedAttachmentData.ocrDocuments)
|
||||
ocrDocuments = [...recognizedAttachmentData.ocrDocuments]
|
||||
ocrFilePreviews = [...recognizedAttachmentData.ocrFilePreviews]
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
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)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
if (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]
|
||||
@@ -359,6 +529,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
})
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const orchestratorOptions = isKnowledgeSession.value
|
||||
? {
|
||||
timeoutMs: 18000,
|
||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||
}
|
||||
: {
|
||||
timeoutMs: 120000,
|
||||
timeoutMessage: '票据归集处理超时,当前仍停留在原草稿,请稍后重试或重新选择附件。'
|
||||
}
|
||||
|
||||
const payload = await runOrchestrator(
|
||||
{
|
||||
source: 'user_message',
|
||||
@@ -393,12 +573,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
...extraContext
|
||||
}
|
||||
},
|
||||
isKnowledgeSession.value
|
||||
? {
|
||||
timeoutMs: 18000,
|
||||
timeoutMessage: '知识问答整理超时,已停止等待。建议缩小问题范围或稍后重试。'
|
||||
}
|
||||
: {}
|
||||
orchestratorOptions
|
||||
)
|
||||
responsePayload = payload
|
||||
flowRunId.value = String(payload?.run_id || '').trim()
|
||||
@@ -413,35 +588,42 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? ''
|
||||
: String(payload?.result?.draft_payload?.claim_id || '').trim() || draftClaimId.value
|
||||
|
||||
replaceMessage(
|
||||
pendingMessage.id,
|
||||
createMessage('assistant', payload?.result?.answer || payload?.result?.message || '智能体已完成处理。', [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
: [],
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
})
|
||||
)
|
||||
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 assistantMessage = createMessage('assistant', payload?.result?.answer || payload?.result?.message || fallbackAnswer, [], {
|
||||
meta: buildMessageMeta(payload, effectiveFileNames),
|
||||
citations: Array.isArray(payload?.result?.citations) ? payload.result.citations : [],
|
||||
suggestedActions: Array.isArray(payload?.result?.suggested_actions)
|
||||
? payload.result.suggested_actions
|
||||
: [],
|
||||
queryPayload: normalizeExpenseQueryPayload(payload?.result?.query_payload),
|
||||
draftPayload: payload?.result?.draft_payload || null,
|
||||
reviewPayload: payload?.result?.review_payload || null,
|
||||
riskFlags: Array.isArray(payload?.result?.risk_flags) ? payload.result.risk_flags : []
|
||||
})
|
||||
replaceMessage(pendingMessage.id, assistantMessage)
|
||||
currentInsight.value = buildAgentInsight(
|
||||
payload,
|
||||
effectiveFileNames,
|
||||
mergeFilePreviews(filePreviews, ocrFilePreviews)
|
||||
)
|
||||
completeFlowResult(payload, flowRunDetail)
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
|
||||
try {
|
||||
await syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已识别,但附件原件保存失败,请重试上传。')
|
||||
}
|
||||
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
|
||||
.then(() => {
|
||||
persistSessionState()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Failed to persist composer attachments to draft claim:', error)
|
||||
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
clearFlowSimulationTimers()
|
||||
@@ -458,6 +640,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
)
|
||||
)
|
||||
currentInsight.value = buildErrorInsight(error, fileNames)
|
||||
persistSessionState()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
composerUploadIntent.value = ''
|
||||
@@ -469,6 +652,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
|
||||
return {
|
||||
confirmPendingAttachmentAssociationInternal: confirmPendingAttachmentAssociation,
|
||||
submitComposerInternal: submitComposer
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user