feat(ai): unify verified expense application workflow
This commit is contained in:
@@ -1131,6 +1131,52 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.application-preview-decision-status {
|
||||
margin-top: 28px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(217, 119, 6, 0.2);
|
||||
border-radius: 10px;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: #92400e;
|
||||
font-size: 12px;
|
||||
font-weight: 720;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.application-preview-action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.application-preview-secondary-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3);
|
||||
border-radius: 8px;
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.application-preview-secondary-btn:hover:not(:disabled),
|
||||
.application-preview-secondary-btn:focus-visible {
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.52);
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
|
||||
}
|
||||
|
||||
.application-preview-secondary-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
.message-answer-markdown :deep(a) {
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
font-weight: 850;
|
||||
|
||||
@@ -246,11 +246,47 @@
|
||||
<span class="application-preview-missing-suffix">。补齐后我再帮您提交申请。</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="ui.buildApplicationPreviewFooterText(message)"
|
||||
v-else-if="ui.canSubmitApplicationPreview(message) && ui.buildApplicationPreviewFooterText(message)"
|
||||
class="application-preview-footer message-answer-content message-answer-markdown"
|
||||
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
|
||||
@click="ui.handleAssistantMarkdownClick($event, message)"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="message.applicationPreview.readyToSubmit"
|
||||
class="application-preview-decision-status"
|
||||
role="status"
|
||||
>
|
||||
<span v-if="message.applicationPreview.decisionTrackingStatus === 'registering'">
|
||||
正在由服务端核验并签发申请预览,请稍候。
|
||||
</span>
|
||||
<span v-else>
|
||||
当前申请预览尚未通过服务端签发。为避免重复或错误建单,保存与提交已暂停。
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.applicationPreview.readyToSubmit"
|
||||
class="application-preview-action-row"
|
||||
>
|
||||
<button
|
||||
v-if="ui.canRetryApplicationPreviewDecision(message)"
|
||||
type="button"
|
||||
class="application-preview-secondary-btn"
|
||||
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
|
||||
@click="ui.retryApplicationPreviewDecision(message)"
|
||||
>
|
||||
<i class="mdi mdi-refresh"></i>
|
||||
<span>重新签发</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="application-preview-secondary-btn"
|
||||
:disabled="!ui.canSaveApplicationPreviewDraft(message)"
|
||||
@click="ui.saveApplicationPreviewDraft(message)"
|
||||
>
|
||||
<i class="mdi mdi-content-save-outline"></i>
|
||||
<span>保存草稿</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
|
||||
@@ -53,6 +53,15 @@ export function useWorkbenchAiActionRouter({
|
||||
|
||||
const actionType = String(action?.action_type || '').trim()
|
||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
||||
if (
|
||||
actionPayload.steward_execute_action
|
||||
&& String(actionPayload.steward_current_task?.task_type || '').trim() === 'expense_application'
|
||||
) {
|
||||
// 费用申请必须先进入结构化预览签发流程,不能从 Steward 计划直接执行保存/提交。
|
||||
aiExpenseDraft.value = null
|
||||
void expenseFlow.startAiApplicationPreviewFromAction(actionPayload)
|
||||
return
|
||||
}
|
||||
if (actionPayload.steward_execute_action) {
|
||||
return executeInlineStewardAction(action, sourceMessage)
|
||||
}
|
||||
|
||||
@@ -105,7 +105,10 @@ export function buildAiApplicationPreviewActionPayload({
|
||||
currentUser = {},
|
||||
conversationId = '',
|
||||
draftPayload = null,
|
||||
requestId = ''
|
||||
requestId = '',
|
||||
entrySource = 'workbench_ai_inline',
|
||||
source = 'workbench',
|
||||
requestSource = 'user_message'
|
||||
} = {}) {
|
||||
const normalizedPreview = normalizeApplicationPreview(applicationPreview || {})
|
||||
const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview)
|
||||
@@ -128,7 +131,7 @@ export function buildAiApplicationPreviewActionPayload({
|
||||
: []
|
||||
|
||||
return {
|
||||
source: 'user_message',
|
||||
source: normalizeText(requestSource) || 'user_message',
|
||||
user_id: username,
|
||||
conversation_id: normalizeText(conversationId) || null,
|
||||
action_type: isSubmit ? 'submit' : 'save_draft',
|
||||
@@ -158,8 +161,8 @@ export function buildAiApplicationPreviewActionPayload({
|
||||
finance_owner_name: resolveUserValue(currentUser, 'financeOwnerName', 'finance_owner_name'),
|
||||
...buildClientTimeContext(),
|
||||
session_type: 'application',
|
||||
entry_source: 'workbench_ai_inline',
|
||||
source: 'workbench',
|
||||
entry_source: normalizeText(entrySource) || 'workbench_ai_inline',
|
||||
source: normalizeText(source) || 'workbench',
|
||||
document_type: 'expense_application',
|
||||
application_stage: 'expense_application',
|
||||
user_input_text: message,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { useTravelReimbursementMessageActions } from './useTravelReimbursementMe
|
||||
import { useTravelReimbursementSuggestedActions } from './useTravelReimbursementSuggestedActions.js'
|
||||
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
|
||||
import { useTravelReimbursementApplicationSubmitConfirm } from './useTravelReimbursementApplicationSubmitConfirm.js'
|
||||
import { useTravelReimbursementApplicationPreviewActions } from './useTravelReimbursementApplicationPreviewActions.js'
|
||||
import { useTravelReimbursementStewardRuntimeDecision } from './useTravelReimbursementStewardRuntimeDecision.js'
|
||||
import { useTravelReimbursementApplicationPreviewDateEditor } from './useTravelReimbursementApplicationPreviewDateEditor.js'
|
||||
import { buildStewardFieldItems, formatStewardMissingFieldList, formatStewardOntologyFields } from './stewardPlanModel.js'
|
||||
@@ -238,6 +239,27 @@ export default {
|
||||
calculateTravelReimbursement,
|
||||
currentUser
|
||||
})
|
||||
const {
|
||||
canRetryApplicationPreviewDecision,
|
||||
canSaveApplicationPreviewDraft,
|
||||
canSubmitApplicationPreview,
|
||||
registerApplicationPreviewDecision,
|
||||
retryApplicationPreviewDecision,
|
||||
runApplicationPreviewAction,
|
||||
saveApplicationPreviewDraft
|
||||
} = useTravelReimbursementApplicationPreviewActions({
|
||||
conversationId,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
emitDraftSaved: (payload) => emit('draft-saved', payload),
|
||||
entrySource: props.entrySource,
|
||||
linkedRequest,
|
||||
persistSessionState,
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
submitting,
|
||||
toast
|
||||
})
|
||||
|
||||
let applyLinkedApplicationPreviewDateSelection = () => false
|
||||
let openApplicationPreviewEditorFromUi = openApplicationPreviewEditor
|
||||
@@ -376,7 +398,7 @@ export default {
|
||||
resolveCurrentUserId
|
||||
})
|
||||
const { confirmPendingAttachmentAssociationInternal, submitComposerInternal } = useTravelReimbursementSubmitComposer({
|
||||
MAX_ATTACHMENTS, activeReviewPayload, activeSessionType, adjustComposerTextareaHeight, attachedFiles, buildAgentInsight, buildClientTimeContext, buildComposerBusinessTimeContext, buildComposerFilePreviews, buildDraftAssociationQueryPayload, buildErrorInsight, buildExpenseIntentConfirmationActions, buildExpenseIntentConfirmationMessage, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, buildMessageMeta, buildOcrDocumentsFromReviewPayload, buildOcrSummaryFromDocuments, buildReviewFormContextFromPayload, clearAttachedFiles, clearFlowSimulationTimers, completeFlowResult, completeFlowStep, composerBusinessTimeDraftTouched, composerBusinessTimeTags, composerDraft, composerUploadIntent, conversationId, createMessage, currentInsight, currentUser, refreshCurrentUserFromBackend, draftClaimId, extractReviewAttachmentNames, failCurrentFlowStep, fetchExpenseClaims, fileInputRef, flowRunId, insightPanelCollapsed, isKnowledgeSession, linkedRequest, mergeBusinessTimeIntoExtraContext, mergeFilePreviews, mergeFilesWithLimit, mergeUploadAttachmentNames, mergeUploadOcrDocuments, messages, nextTick, normalizeExpenseQueryPayload, persistSessionState, props, recognizeOcrFiles, refreshFlowRunDetail, rememberFilePreviews, replaceMessage, resolveComposerDisplaySubmitText, resetFlowRun, resolveComposerSubmitText, reviewInlineForm, runOrchestrator, scrollToBottom, sessionSwitchBusy, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, startExpenseClaimDraftFlowStep, startExpenseIntentConfirmationFlowPreview, startExpenseSceneSelectionFlowPreview, startFlowStep, startSemanticFlowPreview, submitting, syncComposerFilesToDraft, emitOperationCompleted, emitDraftSaved: (payload) => emit('draft-saved', payload), emitRequestUpdated: (payload) => emit('request-updated', payload), toast
|
||||
MAX_ATTACHMENTS, activeReviewPayload, activeSessionType, adjustComposerTextareaHeight, attachedFiles, buildAgentInsight, buildClientTimeContext, buildComposerBusinessTimeContext, buildComposerFilePreviews, buildDraftAssociationQueryPayload, buildErrorInsight, buildExpenseIntentConfirmationActions, buildExpenseIntentConfirmationMessage, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, buildMessageMeta, buildOcrDocumentsFromReviewPayload, buildOcrSummaryFromDocuments, buildReviewFormContextFromPayload, clearAttachedFiles, clearFlowSimulationTimers, completeFlowResult, completeFlowStep, composerBusinessTimeDraftTouched, composerBusinessTimeTags, composerDraft, composerUploadIntent, conversationId, createMessage, currentInsight, currentUser, refreshCurrentUserFromBackend, draftClaimId, extractReviewAttachmentNames, failCurrentFlowStep, fetchExpenseClaims, fileInputRef, flowRunId, insightPanelCollapsed, isKnowledgeSession, linkedRequest, mergeBusinessTimeIntoExtraContext, mergeFilePreviews, mergeFilesWithLimit, mergeUploadAttachmentNames, mergeUploadOcrDocuments, messages, nextTick, normalizeExpenseQueryPayload, persistSessionState, props, recognizeOcrFiles, refreshFlowRunDetail, registerApplicationPreviewDecision, rememberFilePreviews, replaceMessage, resolveComposerDisplaySubmitText, resetFlowRun, resolveComposerSubmitText, reviewInlineForm, runOrchestrator, scrollToBottom, sessionSwitchBusy, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, startExpenseClaimDraftFlowStep, startExpenseIntentConfirmationFlowPreview, startExpenseSceneSelectionFlowPreview, startFlowStep, startSemanticFlowPreview, submitting, syncComposerFilesToDraft, emitOperationCompleted, emitDraftSaved: (payload) => emit('draft-saved', payload), emitRequestUpdated: (payload) => emit('request-updated', payload), toast
|
||||
})
|
||||
let submitComposerFromMessageHandlers = null
|
||||
async function submitComposer(options = {}) {
|
||||
@@ -551,7 +573,7 @@ export default {
|
||||
|
||||
|
||||
const { closeApplicationSubmitConfirm, confirmApplicationSubmit, openApplicationSubmitConfirm, resolveStewardMissingFieldItems } = useTravelReimbursementApplicationSubmitConfirm({
|
||||
activeSessionType, applicationSubmitConfirmDialog, buildStewardFieldItems, createMessage, emitDraftSaved: (payload) => emit('draft-saved', payload), formatStewardMissingFieldList, formatStewardOntologyFields, linkedRequest, messages, nextTick, persistSessionState, reviewActionBusy, scrollToBottom, submitComposer, submitting, toast
|
||||
activeSessionType, applicationSubmitConfirmDialog, buildStewardFieldItems, createMessage, emitDraftSaved: (payload) => emit('draft-saved', payload), formatStewardMissingFieldList, formatStewardOntologyFields, linkedRequest, messages, nextTick, persistSessionState, reviewActionBusy, runApplicationPreviewAction, scrollToBottom, submitComposer, submitting, toast
|
||||
})
|
||||
|
||||
const { handleApplicationSubmitConfirmationText, handleStewardRuntimeDecision } = useTravelReimbursementStewardRuntimeDecision({
|
||||
@@ -568,7 +590,7 @@ export default {
|
||||
submitComposerFromMessageHandlers = submitComposerMessageHandler
|
||||
|
||||
const { insightPanelUi, messageItemUi } = useTravelReimbursementCreateViewUi({
|
||||
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
|
||||
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -121,7 +121,8 @@ export function normalizeInitialConversationMessages(conversation) {
|
||||
queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null,
|
||||
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
|
||||
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
|
||||
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : []
|
||||
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [],
|
||||
applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null
|
||||
})
|
||||
})
|
||||
return markResolvedSuggestedActionMessages(restoredMessages)
|
||||
@@ -135,6 +136,15 @@ export function normalizeSnapshotMessage(message) {
|
||||
delete extras.role
|
||||
delete extras.text
|
||||
delete extras.attachments
|
||||
if (!extras.applicationPreview && extras.pendingApplicationPreviewDecision) {
|
||||
extras.applicationPreview = {
|
||||
...extras.pendingApplicationPreviewDecision,
|
||||
decisionId: '',
|
||||
decisionTrackingStatus: 'registration_failed',
|
||||
decisionRegistrationError: '上次签发请求未完成,请重新签发。'
|
||||
}
|
||||
}
|
||||
delete extras.pendingApplicationPreviewDecision
|
||||
return createMessage(role, text, attachments, extras)
|
||||
}
|
||||
|
||||
@@ -164,12 +174,14 @@ export function serializeSessionMessages(messages) {
|
||||
draftPayload: message.draftPayload || null,
|
||||
reviewPayload: message.reviewPayload || null,
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
budgetReport: message.budgetReport || null,
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
operationFeedback: message.operationFeedback || null,
|
||||
assistantName: message.assistantName || '',
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
pendingApplicationPreviewDecision: message.pendingApplicationPreviewDecision || null,
|
||||
applicationSubmitConfirmed: Boolean(message.applicationSubmitConfirmed),
|
||||
budgetReport: message.budgetReport || null,
|
||||
stewardPlan: message.stewardPlan || null,
|
||||
operationFeedback: message.operationFeedback || null,
|
||||
assistantName: message.assistantName || '',
|
||||
assistantVariant: message.assistantVariant || '',
|
||||
isWelcome: Boolean(message.isWelcome),
|
||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function handleLocalApplicationPreviewFlow({
|
||||
options,
|
||||
persistSessionState,
|
||||
rawText,
|
||||
registerApplicationPreviewDecision = async ({ applicationPreview }) => applicationPreview,
|
||||
replaceMessage,
|
||||
resetFlowRun,
|
||||
resetStewardDelegatedInsightState,
|
||||
@@ -108,7 +109,7 @@ export async function handleLocalApplicationPreviewFlow({
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
||||
const { applicationPreview: generatedApplicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
||||
rawText,
|
||||
selectedBusinessTimeContext,
|
||||
effectiveSessionType,
|
||||
@@ -120,7 +121,7 @@ export async function handleLocalApplicationPreviewFlow({
|
||||
let applicationDateConflict = null
|
||||
try {
|
||||
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
|
||||
applicationDateConflict = findOverlappingApplicationClaim(generatedApplicationPreview, existingClaims)
|
||||
} catch (error) {
|
||||
console.warn('Failed to check overlapping application dates:', error)
|
||||
}
|
||||
@@ -157,7 +158,7 @@ export async function handleLocalApplicationPreviewFlow({
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
rawText,
|
||||
applicationPreview,
|
||||
applicationPreview: generatedApplicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
@@ -179,6 +180,7 @@ export async function handleLocalApplicationPreviewFlow({
|
||||
Date.now() - reviewStartedAt
|
||||
)
|
||||
}
|
||||
let applicationPreview = generatedApplicationPreview
|
||||
if (stewardDelegated) {
|
||||
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
|
||||
applicationPreview,
|
||||
@@ -197,6 +199,13 @@ export async function handleLocalApplicationPreviewFlow({
|
||||
const pauseForMissingFields = slotDecision
|
||||
? String(slotDecision.next_action || '').trim() === 'ask_user'
|
||||
: localPauseForMissingFields
|
||||
if (!pauseForMissingFields) {
|
||||
applicationPreview = await registerApplicationPreviewDecision({
|
||||
applicationPreview,
|
||||
sourceText: rawText,
|
||||
targetMessage: pendingMessage
|
||||
})
|
||||
}
|
||||
const stewardApplicationText = buildStewardSlotDecisionMessage(
|
||||
slotDecision,
|
||||
applicationPreview,
|
||||
@@ -221,6 +230,11 @@ export async function handleLocalApplicationPreviewFlow({
|
||||
}
|
||||
)
|
||||
} else {
|
||||
applicationPreview = await registerApplicationPreviewDecision({
|
||||
applicationPreview,
|
||||
sourceText: rawText,
|
||||
targetMessage: pendingMessage
|
||||
})
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
createAiApplicationRequestId,
|
||||
registerAiApplicationPreviewDecision,
|
||||
runAiApplicationPreviewAction
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
|
||||
|
||||
const DECISION_STATUS_REGISTERED = 'registered'
|
||||
const DECISION_STATUS_REGISTERING = 'registering'
|
||||
const DECISION_STATUS_FAILED = 'registration_failed'
|
||||
const DECISION_STATUS_UNREGISTERED = 'unregistered'
|
||||
|
||||
function resolveRefValue(value, fallback = null) {
|
||||
if (value && typeof value === 'object' && 'value' in value) {
|
||||
return value.value ?? fallback
|
||||
}
|
||||
return value ?? fallback
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error, fallback) {
|
||||
return normalizeText(error?.message || error) || fallback
|
||||
}
|
||||
|
||||
function resolveDecisionSourceText(preview = {}, sourceText = '') {
|
||||
return normalizeText(
|
||||
sourceText
|
||||
|| preview.decisionSourceText
|
||||
|| preview.sourceText
|
||||
|| preview.originalMessage
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplicationDraftPayload(message = {}, draftClaimId = '', linkedRequest = null) {
|
||||
if (message?.draftPayload && typeof message.draftPayload === 'object') {
|
||||
return message.draftPayload
|
||||
}
|
||||
const request = resolveRefValue(linkedRequest, {}) || {}
|
||||
const claimId = normalizeText(
|
||||
draftClaimId
|
||||
|| request.claimId
|
||||
|| request.claim_id
|
||||
)
|
||||
if (!claimId) return null
|
||||
return {
|
||||
claim_id: claimId,
|
||||
claim_no: normalizeText(request.claimNo || request.claim_no || request.id)
|
||||
}
|
||||
}
|
||||
|
||||
export function useTravelReimbursementApplicationPreviewActions({
|
||||
conversationId,
|
||||
currentUser,
|
||||
draftClaimId,
|
||||
emitDraftSaved,
|
||||
entrySource = 'application',
|
||||
linkedRequest,
|
||||
persistSessionState = () => {},
|
||||
reviewActionBusy,
|
||||
sessionSwitchBusy,
|
||||
submitting,
|
||||
toast = () => {}
|
||||
} = {}) {
|
||||
function isInteractionBusy() {
|
||||
return Boolean(
|
||||
resolveRefValue(submitting, false)
|
||||
|| resolveRefValue(reviewActionBusy, false)
|
||||
|| resolveRefValue(sessionSwitchBusy, false)
|
||||
)
|
||||
}
|
||||
|
||||
function isRegisteredApplicationPreview(message) {
|
||||
const preview = normalizeApplicationPreview(message?.applicationPreview || {})
|
||||
return Boolean(
|
||||
preview.readyToSubmit
|
||||
&& normalizeText(preview.decisionId)
|
||||
&& preview.decisionTrackingStatus === DECISION_STATUS_REGISTERED
|
||||
)
|
||||
}
|
||||
|
||||
function canSubmitApplicationPreview(message) {
|
||||
return isRegisteredApplicationPreview(message)
|
||||
}
|
||||
|
||||
function canSaveApplicationPreviewDraft(message) {
|
||||
return !isInteractionBusy() && isRegisteredApplicationPreview(message)
|
||||
}
|
||||
|
||||
function canRetryApplicationPreviewDecision(message) {
|
||||
if (isInteractionBusy() || !message?.applicationPreview) return false
|
||||
if (message.applicationSubmitConfirmed) return false
|
||||
if (normalizeText(message?.draftPayload?.status) === 'submitted') return false
|
||||
const preview = normalizeApplicationPreview(message.applicationPreview)
|
||||
return Boolean(
|
||||
preview.readyToSubmit
|
||||
&& !normalizeText(preview.decisionId)
|
||||
&& [DECISION_STATUS_FAILED, DECISION_STATUS_UNREGISTERED, 'degraded', 'consumed', ''].includes(
|
||||
normalizeText(preview.decisionTrackingStatus)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function buildPendingDecisionPreview(applicationPreview = {}, sourceText = '') {
|
||||
const normalized = normalizeApplicationPreview(applicationPreview)
|
||||
return normalizeApplicationPreview({
|
||||
...normalized,
|
||||
decisionId: '',
|
||||
decisionIssueRequestId: normalizeText(normalized.decisionIssueRequestId)
|
||||
|| createAiApplicationRequestId('preview'),
|
||||
decisionActionRequestId: normalizeText(normalized.decisionActionRequestId)
|
||||
|| createAiApplicationRequestId('action'),
|
||||
decisionSourceText: resolveDecisionSourceText(normalized, sourceText),
|
||||
decisionTrackingStatus: DECISION_STATUS_REGISTERING,
|
||||
decisionRegistrationError: ''
|
||||
})
|
||||
}
|
||||
|
||||
async function registerApplicationPreviewDecision({
|
||||
applicationPreview,
|
||||
sourceText = '',
|
||||
targetMessage = null,
|
||||
preserveCurrentFields = false
|
||||
} = {}) {
|
||||
const normalized = normalizeApplicationPreview(applicationPreview || {})
|
||||
if (!normalized.readyToSubmit) {
|
||||
return normalizeApplicationPreview({
|
||||
...normalized,
|
||||
decisionId: '',
|
||||
decisionSourceText: resolveDecisionSourceText(normalized, sourceText),
|
||||
decisionTrackingStatus: DECISION_STATUS_UNREGISTERED
|
||||
})
|
||||
}
|
||||
if (
|
||||
normalizeText(normalized.decisionId)
|
||||
&& normalized.decisionTrackingStatus === DECISION_STATUS_REGISTERED
|
||||
) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const pendingPreview = buildPendingDecisionPreview(normalized, sourceText)
|
||||
if (targetMessage) {
|
||||
// 网络请求前先把幂等键写入本地会话;超时或刷新后仍可用同一请求继续签发。
|
||||
targetMessage.pendingApplicationPreviewDecision = pendingPreview
|
||||
persistSessionState()
|
||||
}
|
||||
|
||||
try {
|
||||
const registered = await registerAiApplicationPreviewDecision({
|
||||
applicationPreview: pendingPreview,
|
||||
conversationId: normalizeText(resolveRefValue(conversationId, '')),
|
||||
message: pendingPreview.decisionSourceText,
|
||||
requestId: pendingPreview.decisionIssueRequestId
|
||||
})
|
||||
const nextPreview = normalizeApplicationPreview({
|
||||
...registered,
|
||||
...(preserveCurrentFields ? { fields: pendingPreview.fields } : {}),
|
||||
decisionIssueRequestId: pendingPreview.decisionIssueRequestId,
|
||||
decisionActionRequestId: pendingPreview.decisionActionRequestId,
|
||||
decisionSourceText: pendingPreview.decisionSourceText,
|
||||
decisionTrackingStatus: DECISION_STATUS_REGISTERED,
|
||||
decisionRegistrationError: ''
|
||||
})
|
||||
if (targetMessage) {
|
||||
delete targetMessage.pendingApplicationPreviewDecision
|
||||
targetMessage.applicationPreview = nextPreview
|
||||
persistSessionState()
|
||||
}
|
||||
return nextPreview
|
||||
} catch (error) {
|
||||
const failedPreview = normalizeApplicationPreview({
|
||||
...pendingPreview,
|
||||
decisionId: '',
|
||||
decisionSource: '',
|
||||
decisionExpiresAt: '',
|
||||
decisionTrackingStatus: DECISION_STATUS_FAILED,
|
||||
decisionRegistrationError: resolveErrorMessage(error, '服务端暂未完成申请预览签发。')
|
||||
})
|
||||
if (targetMessage) {
|
||||
delete targetMessage.pendingApplicationPreviewDecision
|
||||
targetMessage.applicationPreview = failedPreview
|
||||
persistSessionState()
|
||||
}
|
||||
return failedPreview
|
||||
}
|
||||
}
|
||||
|
||||
async function retryApplicationPreviewDecision(message) {
|
||||
if (!canRetryApplicationPreviewDecision(message)) return false
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const preview = await registerApplicationPreviewDecision({
|
||||
applicationPreview: message.applicationPreview,
|
||||
sourceText: resolveDecisionSourceText(message.applicationPreview),
|
||||
targetMessage: message,
|
||||
preserveCurrentFields: true
|
||||
})
|
||||
if (preview.decisionTrackingStatus === DECISION_STATUS_REGISTERED) {
|
||||
message.text = message.text || ''
|
||||
toast('申请预览已重新签发,可以保存或提交。')
|
||||
return true
|
||||
}
|
||||
toast(preview.decisionRegistrationError || '申请预览签发失败,请稍后重试。')
|
||||
return false
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
persistSessionState()
|
||||
}
|
||||
}
|
||||
|
||||
async function runApplicationPreviewAction(actionType, message) {
|
||||
if (!message?.applicationPreview) {
|
||||
throw new Error('当前没有可处理的申请预览。')
|
||||
}
|
||||
let preview = normalizeApplicationPreview(message.applicationPreview)
|
||||
if (!preview.readyToSubmit) {
|
||||
throw new Error(`请先补充:${preview.missingFields.join('、') || '申请必填信息'}。`)
|
||||
}
|
||||
if (
|
||||
!normalizeText(preview.decisionId)
|
||||
|| preview.decisionTrackingStatus !== DECISION_STATUS_REGISTERED
|
||||
) {
|
||||
// structured preview 必须持有服务端签发结果,禁止降级为客户端观察动作。
|
||||
throw new Error('申请预览尚未通过服务端签发,请先点击“重新签发”。')
|
||||
}
|
||||
|
||||
const actionRequestId = normalizeText(preview.decisionActionRequestId)
|
||||
|| createAiApplicationRequestId('action')
|
||||
preview = normalizeApplicationPreview({
|
||||
...preview,
|
||||
decisionActionRequestId: actionRequestId
|
||||
})
|
||||
message.applicationPreview = preview
|
||||
persistSessionState()
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = await runAiApplicationPreviewAction({
|
||||
actionType,
|
||||
applicationPreview: preview,
|
||||
currentUser: resolveRefValue(currentUser, {}) || {},
|
||||
conversationId: normalizeText(resolveRefValue(conversationId, '')),
|
||||
draftPayload: resolveApplicationDraftPayload(
|
||||
message,
|
||||
normalizeText(resolveRefValue(draftClaimId, '')),
|
||||
linkedRequest
|
||||
),
|
||||
requestId: actionRequestId,
|
||||
entrySource: normalizeText(entrySource) || 'application',
|
||||
source: 'financial_assistant'
|
||||
})
|
||||
} catch (error) {
|
||||
const errorText = resolveErrorMessage(error, '申请动作执行失败。')
|
||||
if (/已过期|已被.*消费|不可使用|不存在或不属于|重新生成预览/.test(errorText)) {
|
||||
message.applicationPreview = normalizeApplicationPreview({
|
||||
...preview,
|
||||
decisionId: '',
|
||||
decisionSource: '',
|
||||
decisionExpiresAt: '',
|
||||
decisionTrackingStatus: DECISION_STATUS_FAILED,
|
||||
decisionRegistrationError: `${errorText} 请重新签发后继续。`,
|
||||
decisionIssueRequestId: createAiApplicationRequestId('preview'),
|
||||
decisionActionRequestId: ''
|
||||
})
|
||||
persistSessionState()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
const nextDraftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: null
|
||||
if (nextDraftPayload) {
|
||||
message.draftPayload = nextDraftPayload
|
||||
const nextClaimId = normalizeText(nextDraftPayload.claim_id || nextDraftPayload.claimId)
|
||||
if (nextClaimId && draftClaimId && typeof draftClaimId === 'object' && 'value' in draftClaimId) {
|
||||
draftClaimId.value = nextClaimId
|
||||
}
|
||||
}
|
||||
|
||||
const nextDecisionId = normalizeText(result.decision_id)
|
||||
const isSaveDraft = actionType === AI_APPLICATION_ACTION_SAVE_DRAFT
|
||||
message.applicationPreview = normalizeApplicationPreview({
|
||||
...preview,
|
||||
decisionId: nextDecisionId,
|
||||
decisionSource: nextDecisionId ? 'server_draft' : '',
|
||||
decisionExpiresAt: normalizeText(result.decision_expires_at),
|
||||
decisionTrackingStatus: nextDecisionId
|
||||
? DECISION_STATUS_REGISTERED
|
||||
: isSaveDraft
|
||||
? DECISION_STATUS_FAILED
|
||||
: 'consumed',
|
||||
decisionRegistrationError: isSaveDraft && !nextDecisionId
|
||||
? '草稿已保存,但后续申请预览签发失败,请重新签发后再提交。'
|
||||
: '',
|
||||
decisionIssueRequestId: isSaveDraft && !nextDecisionId
|
||||
? createAiApplicationRequestId('preview')
|
||||
: '',
|
||||
decisionActionRequestId: nextDecisionId ? createAiApplicationRequestId('action') : ''
|
||||
})
|
||||
persistSessionState()
|
||||
return payload
|
||||
}
|
||||
|
||||
async function saveApplicationPreviewDraft(message) {
|
||||
if (!canSaveApplicationPreviewDraft(message)) return null
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const payload = await runApplicationPreviewAction(
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
message
|
||||
)
|
||||
const draftPayload = payload?.result?.draft_payload || {}
|
||||
const claimId = normalizeText(draftPayload.claim_id || draftPayload.claimId)
|
||||
const claimNo = normalizeText(draftPayload.claim_no || draftPayload.claimNo)
|
||||
if (claimId || claimNo) {
|
||||
emitDraftSaved?.({
|
||||
claimId,
|
||||
claimNo,
|
||||
status: 'draft',
|
||||
approvalStage: normalizeText(draftPayload.approval_stage) || '待提交',
|
||||
documentType: 'application'
|
||||
})
|
||||
}
|
||||
toast('申请草稿已保存。')
|
||||
return payload
|
||||
} catch (error) {
|
||||
toast(resolveErrorMessage(error, '申请草稿保存失败,请稍后重试。'))
|
||||
return null
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
persistSessionState()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canRetryApplicationPreviewDecision,
|
||||
canSaveApplicationPreviewDraft,
|
||||
canSubmitApplicationPreview,
|
||||
registerApplicationPreviewDecision,
|
||||
retryApplicationPreviewDecision,
|
||||
runApplicationPreviewAction,
|
||||
saveApplicationPreviewDraft
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '../../utils/travelApplicationPlanning.js'
|
||||
import { SESSION_TYPE_APPLICATION } from './travelReimbursementConversationModel.js'
|
||||
import { useTravelReimbursementStewardFollowupFlow } from './travelReimbursementStewardFollowupFlow.js'
|
||||
import { AI_APPLICATION_ACTION_SUBMIT } from './useTravelReimbursementApplicationPreviewActions.js'
|
||||
|
||||
export function useTravelReimbursementApplicationSubmitConfirm({
|
||||
activeSessionType,
|
||||
@@ -23,6 +24,7 @@ export function useTravelReimbursementApplicationSubmitConfirm({
|
||||
nextTick,
|
||||
persistSessionState,
|
||||
reviewActionBusy,
|
||||
runApplicationPreviewAction,
|
||||
scrollToBottom,
|
||||
submitComposer,
|
||||
submitting,
|
||||
@@ -103,31 +105,36 @@ export function useTravelReimbursementApplicationSubmitConfirm({
|
||||
const stewardSubmitContinuation = message?.stewardContinuation || null
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const payload = await submitComposer({
|
||||
rawText: applicationSubmitText,
|
||||
userText: String(options.userText || '').trim() || '确认提交',
|
||||
skipUserMessage: Boolean(options.skipUserMessage),
|
||||
pendingText: '正在提交费用申请...',
|
||||
systemGenerated: true,
|
||||
skipScopeGuard: true,
|
||||
skipStewardPlan: true,
|
||||
stewardContinuation: stewardSubmitContinuation,
|
||||
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
||||
feedbackOperationType: 'submit_application',
|
||||
extraContext: {
|
||||
application_preview: applicationPreview,
|
||||
user_input_text: applicationSubmitText,
|
||||
...(applicationEditClaimId
|
||||
? {
|
||||
application_edit_claim_id: applicationEditClaimId,
|
||||
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
|
||||
application_edit_mode: true,
|
||||
draft_claim_id: applicationEditClaimId,
|
||||
selected_claim_id: applicationEditClaimId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
const payload = applicationPreview
|
||||
? await runApplicationPreviewAction(
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
message
|
||||
)
|
||||
: await submitComposer({
|
||||
rawText: applicationSubmitText,
|
||||
userText: String(options.userText || '').trim() || '确认提交',
|
||||
skipUserMessage: Boolean(options.skipUserMessage),
|
||||
pendingText: '正在提交费用申请...',
|
||||
systemGenerated: true,
|
||||
skipScopeGuard: true,
|
||||
skipStewardPlan: true,
|
||||
stewardContinuation: stewardSubmitContinuation,
|
||||
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
||||
feedbackOperationType: 'submit_application',
|
||||
extraContext: {
|
||||
application_preview: applicationPreview,
|
||||
user_input_text: applicationSubmitText,
|
||||
...(applicationEditClaimId
|
||||
? {
|
||||
application_edit_claim_id: applicationEditClaimId,
|
||||
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
|
||||
application_edit_mode: true,
|
||||
draft_claim_id: applicationEditClaimId,
|
||||
selected_claim_id: applicationEditClaimId
|
||||
}
|
||||
: {})
|
||||
}
|
||||
})
|
||||
const draftPayload = payload?.result?.draft_payload || {}
|
||||
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||
const claimId = String(draftPayload.claim_id || '').trim()
|
||||
@@ -140,6 +147,7 @@ export function useTravelReimbursementApplicationSubmitConfirm({
|
||||
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
|
||||
documentType: 'application'
|
||||
})
|
||||
persistSessionState()
|
||||
}
|
||||
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
|
||||
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
|
||||
@@ -162,6 +170,8 @@ export function useTravelReimbursementApplicationSubmitConfirm({
|
||||
if (stewardFollowup) {
|
||||
await pushStewardContinuationMessage(stewardFollowup)
|
||||
}
|
||||
} catch (error) {
|
||||
toast(String(error?.message || '').trim() || '申请提交失败,请稍后重试。')
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ export function useTravelReimbursementCreateViewUi(ctx) {
|
||||
canApplyApplicationPreviewDateSelection: ctx.canApplyApplicationPreviewDateSelection,
|
||||
handleApplicationPreviewEditorKeydown: ctx.handleApplicationPreviewEditorKeydown,
|
||||
buildApplicationPreviewFooterText: ctx.buildApplicationPreviewFooterText,
|
||||
canRetryApplicationPreviewDecision: ctx.canRetryApplicationPreviewDecision,
|
||||
canSaveApplicationPreviewDraft: ctx.canSaveApplicationPreviewDraft,
|
||||
canSubmitApplicationPreview: ctx.canSubmitApplicationPreview,
|
||||
retryApplicationPreviewDecision: ctx.retryApplicationPreviewDecision,
|
||||
saveApplicationPreviewDraft: ctx.saveApplicationPreviewDraft,
|
||||
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
|
||||
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
|
||||
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,
|
||||
|
||||
@@ -74,6 +74,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
normalizeExpenseQueryPayload,
|
||||
persistSessionState,
|
||||
props,
|
||||
registerApplicationPreviewDecision,
|
||||
recognizeOcrFiles,
|
||||
refreshCurrentUserFromBackend,
|
||||
refreshFlowRunDetail,
|
||||
@@ -329,6 +330,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
options,
|
||||
persistSessionState,
|
||||
rawText,
|
||||
registerApplicationPreviewDecision,
|
||||
replaceMessage,
|
||||
resetFlowRun,
|
||||
resetStewardDelegatedInsightState,
|
||||
|
||||
@@ -223,11 +223,44 @@ async function testEditDraftActionCarriesClaimAndEditableFields() {
|
||||
assert.deepEqual(body.context_json.application_editable_fields, ['reason', 'time', 'location', 'transportMode'])
|
||||
}
|
||||
|
||||
async function testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults() {
|
||||
const requests = []
|
||||
global.fetch = async (_url, options) => {
|
||||
requests.push(JSON.parse(options.body))
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { status: 'succeeded', result: {} }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await runAiApplicationPreviewAction({
|
||||
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
applicationPreview: { fields: { reason: '默认工作台来源' } }
|
||||
})
|
||||
await runAiApplicationPreviewAction({
|
||||
actionType: AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
applicationPreview: { fields: { reason: '小财管家来源' } },
|
||||
entrySource: 'application',
|
||||
source: 'financial_assistant',
|
||||
requestSource: 'assistant_preview_action'
|
||||
})
|
||||
|
||||
assert.equal(requests[0].source, 'user_message')
|
||||
assert.equal(requests[0].context_json.entry_source, 'workbench_ai_inline')
|
||||
assert.equal(requests[0].context_json.source, 'workbench')
|
||||
assert.equal(requests[1].source, 'assistant_preview_action')
|
||||
assert.equal(requests[1].context_json.entry_source, 'application')
|
||||
assert.equal(requests[1].context_json.source, 'financial_assistant')
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await testSubmitActionUsesFastPreviewEndpoint()
|
||||
await testSaveDraftActionUsesFastPreviewEndpoint()
|
||||
await testRegistrationUsesServerCanonicalPreview()
|
||||
await testEditDraftActionCarriesClaimAndEditableFields()
|
||||
await testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults()
|
||||
console.log('ai-application-preview-actions tests passed')
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,12 @@ const suggestedActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardRuntimeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
|
||||
const submitConfirmScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageHandlersScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageItemStyles = readFileSync(
|
||||
@@ -44,24 +48,24 @@ test('expense application submit uses rich text link and confirm dialog', () =>
|
||||
assert.match(createViewTemplate, /@confirm="confirmApplicationSubmit"/)
|
||||
assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
messageHandlersScript,
|
||||
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
|
||||
)
|
||||
assert.match(
|
||||
stewardRuntimeScript,
|
||||
/async function confirmApplicationSubmit\(options = \{\}\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
|
||||
submitConfirmScript,
|
||||
/async function confirmApplicationSubmit\(options = \{\}\)[\s\S]*const applicationSubmitText[\s\S]*applicationPreview[\s\S]*runApplicationPreviewAction[\s\S]*AI_APPLICATION_ACTION_SUBMIT[\s\S]*submitComposer/
|
||||
)
|
||||
assert.match(
|
||||
stewardRuntimeScript,
|
||||
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
|
||||
submitConfirmScript,
|
||||
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = applicationPreview/
|
||||
)
|
||||
assert.match(
|
||||
stewardRuntimeScript,
|
||||
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
||||
submitConfirmScript,
|
||||
/emitDraftSaved\(\{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
||||
)
|
||||
assert.match(stewardRuntimeScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
||||
assert.match(stewardRuntimeScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
||||
assert.match(stewardRuntimeScript, /meta:\s*\['行程规划推荐'\]/)
|
||||
assert.match(submitConfirmScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
||||
assert.match(submitConfirmScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
||||
assert.match(submitConfirmScript, /meta:\s*\['行程规划推荐'\]/)
|
||||
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
|
||||
assert.match(suggestedActionsScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
|
||||
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_SKIP/)
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
useTravelReimbursementApplicationPreviewActions
|
||||
} from '../src/views/scripts/useTravelReimbursementApplicationPreviewActions.js'
|
||||
import {
|
||||
normalizeSnapshotMessage,
|
||||
serializeSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
|
||||
import { useTravelReimbursementApplicationSubmitConfirm } from '../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js'
|
||||
|
||||
function createPreview(overrides = {}) {
|
||||
return {
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
time: '2026-07-20 至 2026-07-22',
|
||||
location: '上海',
|
||||
reason: '客户现场实施',
|
||||
days: '3天',
|
||||
transportMode: '火车',
|
||||
amount: '1800元'
|
||||
},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function createActions(overrides = {}) {
|
||||
const persisted = []
|
||||
const emitted = []
|
||||
const toasts = []
|
||||
const state = {
|
||||
conversationId: { value: 'conversation-financial-assistant' },
|
||||
currentUser: { value: { username: 'zhangsan', name: '张三' } },
|
||||
draftClaimId: { value: '' },
|
||||
linkedRequest: { value: null },
|
||||
reviewActionBusy: { value: false },
|
||||
sessionSwitchBusy: { value: false },
|
||||
submitting: { value: false },
|
||||
emitDraftSaved: (payload) => emitted.push(payload),
|
||||
entrySource: 'application',
|
||||
persistSessionState: () => persisted.push(Date.now()),
|
||||
toast: (message) => toasts.push(message),
|
||||
...overrides
|
||||
}
|
||||
return {
|
||||
actions: useTravelReimbursementApplicationPreviewActions(state),
|
||||
emitted,
|
||||
persisted,
|
||||
state,
|
||||
toasts
|
||||
}
|
||||
}
|
||||
|
||||
test('完整 preview 展示前写入稳定签发/动作 request id,并使用 canonical decision', async () => {
|
||||
const targetMessage = { id: 'preview-1', applicationPreview: null }
|
||||
const { actions, persisted } = createActions()
|
||||
let pendingIssueRequestId = ''
|
||||
let pendingActionRequestId = ''
|
||||
|
||||
global.fetch = async (_url, options) => {
|
||||
const body = JSON.parse(options.body)
|
||||
pendingIssueRequestId = targetMessage.pendingApplicationPreviewDecision?.decisionIssueRequestId
|
||||
pendingActionRequestId = targetMessage.pendingApplicationPreviewDecision?.decisionActionRequestId
|
||||
assert.equal(body.request_id, pendingIssueRequestId)
|
||||
assert.ok(pendingActionRequestId.startsWith('action:'))
|
||||
assert.equal(targetMessage.applicationPreview, null)
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
decision_id: 'decision-assistant-1',
|
||||
decision_source: 'rule',
|
||||
expires_at: '2026-07-14T10:30:00Z',
|
||||
application_preview: {
|
||||
fields: {
|
||||
reason: '服务端规范事由',
|
||||
location: '上海'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preview = await actions.registerApplicationPreviewDecision({
|
||||
applicationPreview: createPreview(),
|
||||
sourceText: '7月20日去上海做客户现场实施,坐火车',
|
||||
targetMessage
|
||||
})
|
||||
|
||||
assert.equal(preview.decisionId, 'decision-assistant-1')
|
||||
assert.equal(preview.decisionTrackingStatus, 'registered')
|
||||
assert.equal(preview.fields.reason, '服务端规范事由')
|
||||
assert.equal(preview.decisionIssueRequestId, pendingIssueRequestId)
|
||||
assert.equal(preview.decisionActionRequestId, pendingActionRequestId)
|
||||
assert.equal(targetMessage.pendingApplicationPreviewDecision, undefined)
|
||||
assert.ok(persisted.length >= 2)
|
||||
})
|
||||
|
||||
test('签发失败后 fail-closed,可编辑并以相同 request id 重试签发', async () => {
|
||||
const targetMessage = { id: 'preview-2', applicationPreview: null }
|
||||
const { actions } = createActions()
|
||||
const requestIds = []
|
||||
let attempt = 0
|
||||
global.fetch = async (_url, options) => {
|
||||
attempt += 1
|
||||
requestIds.push(JSON.parse(options.body).request_id)
|
||||
if (attempt === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
async json() {
|
||||
return { detail: '签发服务暂不可用' }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
decision_id: 'decision-assistant-retry',
|
||||
application_preview: { fields: { reason: '服务端规范事由' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const failed = await actions.registerApplicationPreviewDecision({
|
||||
applicationPreview: createPreview(),
|
||||
sourceText: '去上海做客户现场实施',
|
||||
targetMessage
|
||||
})
|
||||
assert.equal(failed.decisionTrackingStatus, 'registration_failed')
|
||||
assert.equal(failed.decisionId, '')
|
||||
assert.equal(actions.canSaveApplicationPreviewDraft(targetMessage), false)
|
||||
await assert.rejects(
|
||||
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, targetMessage),
|
||||
/尚未通过服务端签发/
|
||||
)
|
||||
|
||||
targetMessage.applicationPreview.fields.reason = '用户修正后的事由'
|
||||
const retried = await actions.retryApplicationPreviewDecision(targetMessage)
|
||||
assert.equal(retried, true)
|
||||
assert.equal(requestIds[1], requestIds[0])
|
||||
assert.equal(targetMessage.applicationPreview.fields.reason, '用户修正后的事由')
|
||||
assert.equal(targetMessage.applicationPreview.decisionId, 'decision-assistant-retry')
|
||||
})
|
||||
|
||||
test('动作失败重试复用 action request id;保存成功更新草稿和 next decision', async () => {
|
||||
const message = {
|
||||
id: 'preview-3',
|
||||
applicationPreview: createPreview({
|
||||
decisionId: 'decision-assistant-action',
|
||||
decisionTrackingStatus: 'registered',
|
||||
decisionActionRequestId: 'action:stable-retry'
|
||||
}),
|
||||
draftPayload: null
|
||||
}
|
||||
const { actions, emitted, state } = createActions()
|
||||
const actionBodies = []
|
||||
let attempt = 0
|
||||
global.fetch = async (_url, options) => {
|
||||
attempt += 1
|
||||
actionBodies.push(JSON.parse(options.body))
|
||||
if (attempt === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
async json() {
|
||||
return { detail: '动作服务暂不可用' }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
draft_payload: {
|
||||
claim_id: 'claim-assistant-draft',
|
||||
claim_no: 'AP-20260714-001',
|
||||
approval_stage: '待提交'
|
||||
},
|
||||
decision_id: 'decision-assistant-next',
|
||||
decision_expires_at: '2026-07-14T11:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, message),
|
||||
/动作服务暂不可用/
|
||||
)
|
||||
assert.equal(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
|
||||
await actions.saveApplicationPreviewDraft(message)
|
||||
|
||||
assert.equal(actionBodies[0].request_id, 'action:stable-retry')
|
||||
assert.equal(actionBodies[1].request_id, 'action:stable-retry')
|
||||
assert.equal(actionBodies[1].context_json.entry_source, 'application')
|
||||
assert.equal(actionBodies[1].context_json.source, 'financial_assistant')
|
||||
assert.equal(message.draftPayload.claim_id, 'claim-assistant-draft')
|
||||
assert.equal(state.draftClaimId.value, 'claim-assistant-draft')
|
||||
assert.equal(message.applicationPreview.decisionId, 'decision-assistant-next')
|
||||
assert.notEqual(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
|
||||
assert.equal(emitted[0].status, 'draft')
|
||||
})
|
||||
|
||||
test('decision 过期后清空旧 ID 并开放重新签发', async () => {
|
||||
const message = {
|
||||
id: 'preview-expired',
|
||||
applicationPreview: createPreview({
|
||||
decisionId: 'decision-expired',
|
||||
decisionTrackingStatus: 'registered',
|
||||
decisionIssueRequestId: 'preview:old',
|
||||
decisionActionRequestId: 'action:expired'
|
||||
})
|
||||
}
|
||||
const { actions } = createActions()
|
||||
global.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 409,
|
||||
async json() {
|
||||
return { detail: '该预览决策已过期,请重新生成预览。' }
|
||||
}
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, message),
|
||||
/已过期/
|
||||
)
|
||||
assert.equal(message.applicationPreview.decisionId, '')
|
||||
assert.equal(message.applicationPreview.decisionTrackingStatus, 'registration_failed')
|
||||
assert.notEqual(message.applicationPreview.decisionIssueRequestId, 'preview:old')
|
||||
assert.equal(message.applicationPreview.decisionActionRequestId, '')
|
||||
assert.equal(actions.canRetryApplicationPreviewDecision(message), true)
|
||||
})
|
||||
|
||||
test('本地快照保留签发与动作 request id,并把中断中的签发恢复为可重试状态', () => {
|
||||
const pending = createPreview({
|
||||
decisionIssueRequestId: 'preview:recover',
|
||||
decisionActionRequestId: 'action:recover',
|
||||
decisionTrackingStatus: 'registering'
|
||||
})
|
||||
const serialized = serializeSessionMessages([{
|
||||
id: 'preview-4',
|
||||
role: 'assistant',
|
||||
text: '正在签发',
|
||||
attachments: [],
|
||||
pendingApplicationPreviewDecision: pending
|
||||
}])
|
||||
assert.equal(
|
||||
serialized[0].pendingApplicationPreviewDecision.decisionIssueRequestId,
|
||||
'preview:recover'
|
||||
)
|
||||
|
||||
const restored = normalizeSnapshotMessage(serialized[0])
|
||||
assert.equal(restored.applicationPreview.decisionIssueRequestId, 'preview:recover')
|
||||
assert.equal(restored.applicationPreview.decisionActionRequestId, 'action:recover')
|
||||
assert.equal(restored.applicationPreview.decisionTrackingStatus, 'registration_failed')
|
||||
})
|
||||
|
||||
function createSubmitConfirm({ message, runApplicationPreviewAction, submitComposer }) {
|
||||
const applicationSubmitConfirmDialog = { value: { open: true, message } }
|
||||
const reviewActionBusy = { value: false }
|
||||
const toasts = []
|
||||
const api = useTravelReimbursementApplicationSubmitConfirm({
|
||||
activeSessionType: { value: 'application' },
|
||||
applicationSubmitConfirmDialog,
|
||||
buildStewardFieldItems: () => [],
|
||||
createMessage: (role, text, _attachments, extras = {}) => ({ role, text, ...extras }),
|
||||
emitDraftSaved: () => {},
|
||||
formatStewardMissingFieldList: () => '',
|
||||
formatStewardOntologyFields: () => '',
|
||||
linkedRequest: { value: null },
|
||||
messages: { value: [] },
|
||||
nextTick: (callback) => callback?.(),
|
||||
persistSessionState: () => {},
|
||||
reviewActionBusy,
|
||||
runApplicationPreviewAction,
|
||||
scrollToBottom: () => {},
|
||||
submitComposer,
|
||||
submitting: { value: false },
|
||||
toast: (text) => toasts.push(text)
|
||||
})
|
||||
return { api, toasts }
|
||||
}
|
||||
|
||||
test('structured preview 只走认证 action;未签发失败时也不降级到 Orchestrator', async () => {
|
||||
let actionCalls = 0
|
||||
let orchestratorCalls = 0
|
||||
const unsignedMessage = {
|
||||
applicationPreview: createPreview({
|
||||
decisionTrackingStatus: 'registration_failed',
|
||||
decisionId: ''
|
||||
})
|
||||
}
|
||||
const { api, toasts } = createSubmitConfirm({
|
||||
message: unsignedMessage,
|
||||
runApplicationPreviewAction: async () => {
|
||||
actionCalls += 1
|
||||
throw new Error('申请预览尚未通过服务端签发')
|
||||
},
|
||||
submitComposer: async () => {
|
||||
orchestratorCalls += 1
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
await api.confirmApplicationSubmit()
|
||||
assert.equal(actionCalls, 1)
|
||||
assert.equal(orchestratorCalls, 0)
|
||||
assert.match(toasts[0], /尚未通过服务端签发/)
|
||||
})
|
||||
|
||||
test('旧消息没有 structured preview 时保留 Orchestrator fallback', async () => {
|
||||
let actionCalls = 0
|
||||
let orchestratorCalls = 0
|
||||
const { api } = createSubmitConfirm({
|
||||
message: { text: '旧版确认消息' },
|
||||
runApplicationPreviewAction: async () => {
|
||||
actionCalls += 1
|
||||
return null
|
||||
},
|
||||
submitComposer: async () => {
|
||||
orchestratorCalls += 1
|
||||
return { status: 'succeeded', result: {} }
|
||||
}
|
||||
})
|
||||
|
||||
await api.confirmApplicationSubmit()
|
||||
assert.equal(actionCalls, 0)
|
||||
assert.equal(orchestratorCalls, 1)
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
normalizeInitialConversationMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
|
||||
|
||||
test('Orchestrator 会话恢复 assistant 的 application preview,并保留既有结果字段', () => {
|
||||
const applicationPreview = {
|
||||
decision_id: 'decision-session-1',
|
||||
fields: {
|
||||
applicationType: '差旅费用申请',
|
||||
location: '上海',
|
||||
reason: '客户现场实施'
|
||||
}
|
||||
}
|
||||
const [message] = normalizeInitialConversationMessages({
|
||||
messages: [{
|
||||
id: 'assistant-message-1',
|
||||
role: 'assistant',
|
||||
content: '申请预览已生成',
|
||||
created_at: '2026-07-14T08:00:00Z',
|
||||
message_json: {
|
||||
orchestrator_payload: {
|
||||
result: {
|
||||
application_preview: applicationPreview,
|
||||
draft_payload: { claim_id: 'claim-draft-1' },
|
||||
review_payload: { summary: '复核通过' },
|
||||
risk_flags: [{ code: 'policy-limit' }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
assert.deepEqual(message.applicationPreview, applicationPreview)
|
||||
assert.deepEqual(message.draftPayload, { claim_id: 'claim-draft-1' })
|
||||
assert.deepEqual(message.reviewPayload, { summary: '复核通过' })
|
||||
assert.deepEqual(message.riskFlags, [{ code: 'policy-limit' }])
|
||||
})
|
||||
|
||||
test('user 消息不能从 payload 恢复 application preview', () => {
|
||||
const [message] = normalizeInitialConversationMessages({
|
||||
messages: [{
|
||||
id: 'user-message-1',
|
||||
role: 'user',
|
||||
content: '保存草稿',
|
||||
message_json: {
|
||||
orchestrator_payload: {
|
||||
result: {
|
||||
application_preview: { fields: { reason: '伪造预览' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
assert.equal(message.applicationPreview, null)
|
||||
})
|
||||
@@ -305,148 +305,52 @@ test('workbench standalone draft action asks before creating a new reimbursement
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench steward executable submit action runs precheck before submit and writes result message', async () => {
|
||||
const requests = []
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = async (_url, options = {}) => {
|
||||
const body = JSON.parse(String(options.body || '{}'))
|
||||
requests.push(body)
|
||||
if (body.action_type === 'run_duplicate_precheck') {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
action_type: 'run_duplicate_precheck',
|
||||
status: 'succeeded',
|
||||
message: '未发现重复或冲突申请,可以继续提交。',
|
||||
result_payload: {
|
||||
status: 'ok',
|
||||
blocking: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
action_type: 'submit_application',
|
||||
status: 'succeeded',
|
||||
message: '申请已提交审批。',
|
||||
result_payload: {
|
||||
draft_payload: {
|
||||
claim_id: 'claim-app-1',
|
||||
claim_no: 'A1BCDEF2'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
test('workbench steward application side effect opens signed preview before submit', () => {
|
||||
let previewPayload = null
|
||||
const router = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft: { value: null },
|
||||
applicationFlow: {
|
||||
isInlineSuggestedActionDisabled: () => false,
|
||||
executeInlineApplicationPreviewAction: () => {}
|
||||
},
|
||||
assistantDraft: { value: '' },
|
||||
attachmentFlow: { confirmAiAttachmentAssociation: () => {} },
|
||||
emit: () => {},
|
||||
expenseFlow: {
|
||||
linkAiExpenseApplication: () => {},
|
||||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||
startAiApplicationPreviewFromAction: (payload) => {
|
||||
previewPayload = payload
|
||||
},
|
||||
startAiExpenseDraft: () => {}
|
||||
},
|
||||
focusAiModeInput: () => {},
|
||||
hasInlineAttachmentOcrDetails: () => false,
|
||||
resolveLatestInlineUserPrompt: () => '',
|
||||
selectedFiles: { value: [] },
|
||||
startInlineConversation: () => {},
|
||||
toast: () => {},
|
||||
toggleInlineAttachmentOcrDetails: () => {}
|
||||
})
|
||||
|
||||
try {
|
||||
const messages = []
|
||||
let messageSeq = 0
|
||||
const createInlineMessage = (role, content, options = {}) => ({
|
||||
id: options.id || `msg-${++messageSeq}`,
|
||||
role,
|
||||
content,
|
||||
pending: Boolean(options.pending),
|
||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : []
|
||||
})
|
||||
const replaceInlineMessage = (id, nextMessage) => {
|
||||
const index = messages.findIndex((item) => item.id === id)
|
||||
if (index >= 0) {
|
||||
messages.splice(index, 1, nextMessage)
|
||||
}
|
||||
}
|
||||
let persisted = false
|
||||
const router = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft: { value: null },
|
||||
applicationFlow: {
|
||||
isInlineSuggestedActionDisabled: () => false,
|
||||
executeInlineApplicationPreviewAction: () => {}
|
||||
},
|
||||
assistantDraft: { value: '' },
|
||||
attachmentFlow: {
|
||||
confirmAiAttachmentAssociation: () => {}
|
||||
},
|
||||
conversationMessages: { value: messages },
|
||||
createInlineMessage,
|
||||
emit: () => {},
|
||||
expenseFlow: {
|
||||
linkAiExpenseApplication: () => {},
|
||||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||
startAiApplicationPreviewFromAction: () => {},
|
||||
startAiExpenseDraft: () => {}
|
||||
},
|
||||
focusAiModeInput: () => {},
|
||||
hasInlineAttachmentOcrDetails: () => false,
|
||||
persistCurrentConversation: () => {
|
||||
persisted = true
|
||||
},
|
||||
replaceInlineMessage,
|
||||
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交',
|
||||
scrollInlineConversationToBottom: () => {},
|
||||
selectedFiles: { value: [] },
|
||||
startInlineConversation: () => {},
|
||||
toast: () => {},
|
||||
toggleInlineAttachmentOcrDetails: () => {}
|
||||
})
|
||||
const sourceMessage = {
|
||||
suggestedActionsLocked: false
|
||||
router.handleInlineSuggestedAction({
|
||||
label: '确认提交申请',
|
||||
action_type: 'switch_session',
|
||||
payload: {
|
||||
steward_execute_action: true,
|
||||
steward_action_type: 'submit_application',
|
||||
steward_current_task: {
|
||||
task_id: 'task-app-1',
|
||||
task_type: 'expense_application',
|
||||
requested_action: 'submit'
|
||||
},
|
||||
carry_text: '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交'
|
||||
}
|
||||
})
|
||||
|
||||
await router.handleInlineSuggestedAction({
|
||||
label: '确认提交申请',
|
||||
action_type: 'switch_session',
|
||||
payload: {
|
||||
steward_execute_action: true,
|
||||
steward_plan_id: 'plan-submit-1',
|
||||
steward_action_type: 'submit_application',
|
||||
steward_action_requires_confirmation: true,
|
||||
steward_action_step: {
|
||||
step_id: 'task-app-1:05',
|
||||
action_type: 'submit_application',
|
||||
requires_confirmation: true
|
||||
},
|
||||
steward_current_task: {
|
||||
task_id: 'task-app-1',
|
||||
task_type: 'expense_application',
|
||||
assigned_agent: 'application_assistant',
|
||||
title: '上海出差申请',
|
||||
summary: '2026-02-20 至 2026-02-23 去上海出差,交通火车。',
|
||||
requested_action: 'submit',
|
||||
ontology_fields: {
|
||||
expense_type: 'travel',
|
||||
time_range: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '辅助国网仿生产服务器部署',
|
||||
transport_mode: 'train'
|
||||
},
|
||||
missing_fields: [],
|
||||
action_steps: [
|
||||
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck' },
|
||||
{ step_id: 'task-app-1:05', action_type: 'submit_application', requires_confirmation: true }
|
||||
]
|
||||
},
|
||||
carry_text: '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交'
|
||||
}
|
||||
}, sourceMessage)
|
||||
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(requests[0].action_type, 'run_duplicate_precheck')
|
||||
assert.equal(requests[1].action_type, 'submit_application')
|
||||
assert.equal(requests[1].confirmed, true)
|
||||
assert.equal(requests[1].context_json.precheck_result.status, 'ok')
|
||||
assert.equal(sourceMessage.suggestedActionsLocked, true)
|
||||
assert.equal(persisted, true)
|
||||
assert.match(messages.at(-1).content, /申请已提交审批/)
|
||||
assert.equal(messages.at(-1).suggestedActions[0].action_type, 'open_application_detail')
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
assert.equal(previewPayload?.steward_action_type, 'submit_application')
|
||||
assert.equal(previewPayload?.steward_current_task?.task_type, 'expense_application')
|
||||
assert.match(previewPayload?.carry_text || '', /直接提交/)
|
||||
})
|
||||
|
||||
test('workbench steward continue-next-task reimbursement prefills ontology and forwards remaining tasks', () => {
|
||||
|
||||
Reference in New Issue
Block a user