feat(ai): unify verified expense application workflow

This commit is contained in:
caoxiaozhu
2026-07-14 16:03:05 +08:00
parent 5b24630710
commit 211f85d981
33 changed files with 2793 additions and 405 deletions

View File

@@ -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')
}

View File

@@ -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/)

View File

@@ -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)
})

View File

@@ -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)
})

View File

@@ -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', () => {