feat(web): AI 工作台文件预览/附件关联任务与草稿分支
- 新增 WorkbenchAiFilePreviewDialog 附件预览对话框及 useWorkbenchAiFilePreview,附件支持点击预览 - 新增 attachmentAssociationJobs/linkedReimbursementDraftJobs 前端服务与对应 composable,接入后台任务轮询与状态展示 - 新增 travelReimbursementDraftBranchModel 草稿分支模型,报销关联门控支持跳过/选择草稿 - PersonalWorkbenchAiMode 及各 composable(expense/document/steward/application-preview/attachment-association)重构适配,WorkbenchAiComposer/FileStrip 样式与交互完善 - DocumentsCenter/ReceiptFolder/TravelReimbursementCreate 等视图及 scripts 重构,风险/差旅规划/审批等工具适配 - 新增/更新前端测试:application-result-card、reimbursement-list-preview-fetch、guided-flow、composer-components 等
This commit is contained in:
@@ -9,6 +9,10 @@ const routerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/router/index.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const backendUnavailableScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/BackendUnavailableRouteView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('app route guard allows stale healthy state when health check times out', () => {
|
||||
assert.match(routerScript, /checkBackendHealth\(\{\s*allowStaleOnTimeout:\s*true\s*\}\)/)
|
||||
@@ -49,3 +53,11 @@ test('backend health timeout does not block app rendering when stale fallback is
|
||||
global.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('backend unavailable page automatically recovers after service startup race', () => {
|
||||
assert.match(backendUnavailableScript, /onMounted\(\s*\(\)\s*=>\s*\{/)
|
||||
assert.match(backendUnavailableScript, /startAutoRecover\(\)/)
|
||||
assert.match(backendUnavailableScript, /globalThis\.setInterval/)
|
||||
assert.match(backendUnavailableScript, /router\.replace\(loggedIn\.value \? resolveEntryRoute\(\) : \{ name: 'login' \}\)/)
|
||||
assert.match(backendUnavailableScript, /onBeforeUnmount\(\s*\(\)\s*=>\s*\{/)
|
||||
})
|
||||
|
||||
@@ -268,7 +268,7 @@ test('assistant scope guard blocks unsupported non-financial intent', () => {
|
||||
Array.from({ length: 4 }, () => ASSISTANT_SCOPE_ACTION_SWITCH)
|
||||
)
|
||||
assert.match(greetingGuard.text, /小财管家暂时不处理「你好」/)
|
||||
assert.match(greetingGuard.text, /你可以直接点下面的场景继续/)
|
||||
assert.match(greetingGuard.text, /您可以直接点下面的场景继续/)
|
||||
assert.equal(guard.suggestedActions.length, 4)
|
||||
assert.equal(guard.blocked, true)
|
||||
assert.equal(guard.targetSessionType, '')
|
||||
@@ -466,6 +466,28 @@ test('application preview parses same-month shorthand date range', () => {
|
||||
assert.doesNotMatch(preview.fields.reason, /小财管家继续执行/)
|
||||
})
|
||||
|
||||
test('application preview splits compact destination and business purpose', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'2026-02-20 至 2026-02-23,去上海辅助国网仿生产服务器部署,火车',
|
||||
{
|
||||
name: '曹笑竹',
|
||||
departmentName: '技术部',
|
||||
position: '财务智能化产品经理',
|
||||
managerName: '向万红',
|
||||
grade: 'P5'
|
||||
},
|
||||
{ today: '2026-06-09' }
|
||||
)
|
||||
|
||||
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||
assert.equal(preview.fields.days, '4天')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.deepEqual(preview.validationIssues, [])
|
||||
})
|
||||
|
||||
test('application preview blocks submit when date range conflicts with explicit days', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请2月20-23日去上海出差3天,辅助国网仿生产服务器部署,火车',
|
||||
@@ -569,6 +591,40 @@ test('application preview trusts model-refined fields over noisy source candidat
|
||||
assert.deepEqual(preview.validationIssues, [])
|
||||
})
|
||||
|
||||
test('application preview normalizes model-refined location mixed with business content', () => {
|
||||
const rawText = '申请2月20日-23日火车出差,事由:辅助国网仿生产服务器部署'
|
||||
const preview = buildModelRefinedApplicationPreview(
|
||||
buildLocalApplicationPreview(rawText, { name: '曹笑竹', grade: 'P5' }, { today: '2026-06-09' }),
|
||||
{
|
||||
parse_strategy: 'llm_primary',
|
||||
entities: [
|
||||
{ type: 'expense_type', value: '差旅费', normalized_value: 'travel' },
|
||||
{ type: 'location', value: '上海辅助国网仿生产服务器', normalized_value: '上海辅助国网仿生产服务器' },
|
||||
{ type: 'reason', value: '辅助国网仿生产服务器部署', normalized_value: '辅助国网仿生产服务器部署' },
|
||||
{ type: 'transport_mode', value: '火车', normalized_value: '火车' },
|
||||
{ type: 'policy_total_amount', value: '2120元', normalized_value: '2120' }
|
||||
],
|
||||
time_range: {
|
||||
start_date: '2026-02-20',
|
||||
end_date: '2026-02-23'
|
||||
},
|
||||
missing_slots: []
|
||||
},
|
||||
rawText,
|
||||
{ name: '曹笑竹', grade: 'P5' }
|
||||
)
|
||||
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, { grade: 'P5', location: '武汉' })
|
||||
const footer = buildApplicationPreviewFooterMessage(preview)
|
||||
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.deepEqual(preview.validationIssues, [])
|
||||
assert.match(footer, /#application-submit/)
|
||||
assert.equal(estimateRequest.canCalculate, true)
|
||||
assert.equal(estimateRequest.payload.location, '上海')
|
||||
})
|
||||
|
||||
test('application preview blocks submit when transport candidates conflict', () => {
|
||||
const preview = buildLocalApplicationPreview(
|
||||
'申请2月20-23日去上海出差4天,辅助国网仿生产服务器部署,出行方式:飞机,坐火车',
|
||||
@@ -1054,7 +1110,7 @@ test('steward application missing transport blocks preview table', () => {
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
||||
assert.doesNotMatch(submitComposerScript, /请先告诉我你打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
assert.doesNotMatch(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
|
||||
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
|
||||
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
|
||||
|
||||
@@ -57,6 +57,9 @@ function testReceiptFolderViewSurface() {
|
||||
assert.match(view, /buildReceiptFile\(item\)/)
|
||||
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
|
||||
assert.match(view, /emit\('open-assistant'/)
|
||||
assert.match(view, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
|
||||
assert.match(view, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(view, /const claims = await fetchExpenseClaims\(\)/)
|
||||
}
|
||||
|
||||
function testReceiptFolderServiceContract() {
|
||||
@@ -160,18 +163,19 @@ function testReceiptFolderDetailLayoutAdjustments() {
|
||||
|
||||
function testAssistantUnlinkedReceiptPrompt() {
|
||||
const submitComposer = readProjectFile('web/src/views/scripts/useTravelReimbursementSubmitComposer.js')
|
||||
const assistantView = readProjectFile('web/src/views/scripts/TravelReimbursementCreateView.js')
|
||||
const attachmentFlow = readProjectFile('web/src/views/scripts/travelReimbursementSubmitAttachmentFlow.js')
|
||||
const suggestedActions = readProjectFile('web/src/views/scripts/useTravelReimbursementSuggestedActions.js')
|
||||
|
||||
assert.match(submitComposer, /fetchReceiptFolderItems/)
|
||||
assert.match(submitComposer, /promptUnlinkedReceiptFolderIfNeeded/)
|
||||
assert.match(submitComposer, /fetchReceiptFolderItems\('unlinked'\)/)
|
||||
assert.match(submitComposer, /skipReceiptFolderUnlinkedPrompt/)
|
||||
assert.match(submitComposer, /open_receipt_folder/)
|
||||
assert.match(submitComposer, /continue_upload_with_unlinked_receipts/)
|
||||
assert.match(assistantView, /actionType === 'open_receipt_folder'/)
|
||||
assert.match(assistantView, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
|
||||
assert.match(assistantView, /actionType === 'continue_upload_with_unlinked_receipts'/)
|
||||
assert.match(assistantView, /skipReceiptFolderUnlinkedPrompt: true/)
|
||||
assert.match(attachmentFlow, /fetchReceiptFolderItems\('unlinked'\)/)
|
||||
assert.match(attachmentFlow, /skipReceiptFolderUnlinkedPrompt/)
|
||||
assert.match(attachmentFlow, /open_receipt_folder/)
|
||||
assert.match(attachmentFlow, /continue_upload_with_unlinked_receipts/)
|
||||
assert.match(suggestedActions, /actionType === 'open_receipt_folder'/)
|
||||
assert.match(suggestedActions, /router\.push\(\{ name: 'app-receiptFolder' \}\)/)
|
||||
assert.match(suggestedActions, /actionType === 'continue_upload_with_unlinked_receipts'/)
|
||||
assert.match(suggestedActions, /skipReceiptFolderUnlinkedPrompt: true/)
|
||||
}
|
||||
|
||||
function run() {
|
||||
|
||||
37
web/tests/reimbursement-list-preview-fetch.test.mjs
Normal file
37
web/tests/reimbursement-list-preview-fetch.test.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const root = process.cwd()
|
||||
|
||||
function readProjectFile(path) {
|
||||
return readFileSync(join(root, path), 'utf8')
|
||||
}
|
||||
|
||||
test('workbench and document list refreshes use preview pagination', () => {
|
||||
const useRequests = readProjectFile('web/src/composables/useRequests.js')
|
||||
const useAppShell = readProjectFile('web/src/composables/useAppShell.js')
|
||||
const documentsCenter = readProjectFile('web/src/views/DocumentsCenterView.vue')
|
||||
const approvalCenter = readProjectFile('web/src/views/scripts/ApprovalCenterView.js')
|
||||
const archiveCenter = readProjectFile('web/src/views/scripts/ArchiveCenterView.js')
|
||||
|
||||
assert.match(useRequests, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
|
||||
assert.match(useRequests, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(useRequests, /fetchAllExpenseClaims\(\)/)
|
||||
|
||||
assert.match(useAppShell, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
|
||||
assert.match(useAppShell, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(useAppShell, /fetchAllApprovalExpenseClaims\(\)/)
|
||||
|
||||
assert.match(documentsCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.match(documentsCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(documentsCenter, /fetchAllApprovalExpenseClaims\(\)/)
|
||||
assert.doesNotMatch(documentsCenter, /fetchAllArchivedExpenseClaims\(\)/)
|
||||
|
||||
assert.match(approvalCenter, /fetchApprovalExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(approvalCenter, /fetchApprovalExpenseClaims\(\)/)
|
||||
|
||||
assert.match(archiveCenter, /fetchArchivedExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(archiveCenter, /fetchArchivedExpenseClaims\(\)/)
|
||||
})
|
||||
@@ -20,12 +20,12 @@ test('steward plan summary uses warm guidance copy for application flow', () =>
|
||||
next_action: 'confirm_create_application'
|
||||
})
|
||||
|
||||
assert.match(message, /我先帮你把步骤理清楚/)
|
||||
assert.match(message, /我先看了一下,你这次主要是 \*\*1 个事项\*\*/)
|
||||
assert.match(message, /我先帮您把步骤理清楚/)
|
||||
assert.match(message, /我先看了一下,您这次主要是 \*\*1 个事项\*\*/)
|
||||
assert.match(message, /为了不让步骤混在一起/)
|
||||
assert.match(message, /我会请申请助手先把申请单草稿整理出来/)
|
||||
assert.match(message, /你看这个顺序是否合适/)
|
||||
assert.match(message, /需要补充的信息会在具体步骤里再温和提醒你/)
|
||||
assert.match(message, /这步交给申请助手——先把申请单草稿拉出来给您过目/)
|
||||
assert.match(message, /您看这个顺序是否合适/)
|
||||
assert.match(message, /需要补充的信息会在具体步骤里再温和提醒/)
|
||||
assert.doesNotMatch(message, /我会这样推进/)
|
||||
assert.doesNotMatch(message, /不会一次性把所有动作都执行掉/)
|
||||
assert.doesNotMatch(message, /交给申请助手生成申请单核对结果/)
|
||||
@@ -59,8 +59,8 @@ test('steward plan summary guides bare reimbursement intent into scene selection
|
||||
|
||||
const message = buildStewardPlanMessageText(plan)
|
||||
|
||||
assert.match(message, /我来带你发起报销/)
|
||||
assert.match(message, /你现在只说了要报销/)
|
||||
assert.match(message, /我来带您发起报销/)
|
||||
assert.match(message, /您现在只说了要报销/)
|
||||
assert.match(message, /先选报销场景/)
|
||||
assert.match(message, /差旅费、交通费、住宿费/)
|
||||
assert.doesNotMatch(message, /步骤混在一起/)
|
||||
|
||||
@@ -94,6 +94,10 @@ const submitComposerScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const submitDraftPreflightScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementSubmitDraftPreflight.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const messageHandlersScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -477,6 +481,12 @@ test('guided flow is local until final confirmation or collected query handoff',
|
||||
assert.doesNotMatch(guidedFlowScript, /startExpenseClaimDraftFlowStep/)
|
||||
assert.match(guidedModelScript, /review_action:\s*['"]save_draft['"]/)
|
||||
assert.match(guidedFlowScript, /fetchExpenseClaims/)
|
||||
assert.match(guidedFlowScript, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
|
||||
assert.match(guidedFlowScript, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.match(submitDraftPreflightScript, /REIMBURSEMENT_LIST_PREVIEW_PARAMS/)
|
||||
assert.match(submitDraftPreflightScript, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
|
||||
assert.doesNotMatch(guidedFlowScript, /fetchExpenseClaims\(\)/)
|
||||
assert.doesNotMatch(submitDraftPreflightScript, /fetchExpenseClaims\(\)/)
|
||||
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
|
||||
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/)
|
||||
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/)
|
||||
|
||||
@@ -434,6 +434,13 @@ test('AI advice ignores approval opinions and flow logs as risks', () => {
|
||||
severity: 'info',
|
||||
label: '财务审核通过',
|
||||
message: '周晓彤 已完成财务审核,进入归档入账。'
|
||||
},
|
||||
{
|
||||
source: 'application_link_sync',
|
||||
event_type: 'expense_application_reimbursement_deleted',
|
||||
severity: 'warning',
|
||||
label: '关联报销单已删除',
|
||||
message: '关联报销单 RDELETE01 已删除,申请单已回到待关联状态。'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ import test from 'node:test'
|
||||
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
import { buildStewardSuggestedActions } from '../src/views/scripts/stewardPlanModel.js'
|
||||
import { useWorkbenchAiActionRouter } from '../src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js'
|
||||
import {
|
||||
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
|
||||
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
|
||||
|
||||
test('workbench steward application confirmation opens inline application preview directly', () => {
|
||||
const [action] = buildStewardSuggestedActions({
|
||||
@@ -136,3 +140,108 @@ test('workbench reimbursement skip link action opens new reimbursement flow', ()
|
||||
label: '不关联,单独新建报销单'
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench draft continuation action asks for attachments or description', () => {
|
||||
let continuationPayload = null
|
||||
let fallbackConversationStarted = false
|
||||
const router = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft: { value: null },
|
||||
applicationFlow: {
|
||||
isInlineSuggestedActionDisabled: () => false,
|
||||
executeInlineApplicationPreviewAction: () => {}
|
||||
},
|
||||
assistantDraft: { value: '' },
|
||||
attachmentFlow: {
|
||||
confirmAiAttachmentAssociation: () => {}
|
||||
},
|
||||
emit: () => {},
|
||||
expenseFlow: {
|
||||
linkAiExpenseApplication: () => {},
|
||||
promptAiReimbursementDraftContinuation: (payload) => {
|
||||
continuationPayload = payload
|
||||
},
|
||||
promptStandaloneReimbursementDraftCreation: () => {},
|
||||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||
startAiApplicationPreviewFromAction: () => {},
|
||||
startAiExpenseDraft: () => {},
|
||||
startAiReimbursementAssociationGate: () => {}
|
||||
},
|
||||
focusAiModeInput: () => {},
|
||||
hasInlineAttachmentOcrDetails: () => false,
|
||||
resolveLatestInlineUserPrompt: () => '',
|
||||
selectedFiles: { value: [] },
|
||||
startInlineConversation: () => {
|
||||
fallbackConversationStarted = true
|
||||
},
|
||||
toast: () => {},
|
||||
toggleInlineAttachmentOcrDetails: () => {}
|
||||
})
|
||||
|
||||
router.handleInlineSuggestedAction({
|
||||
label: '继续关联草稿 RE-202606-010',
|
||||
action_type: CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
payload: {
|
||||
claim_id: 'draft-travel-1',
|
||||
claim_no: 'RE-202606-010',
|
||||
original_message: '我要报销'
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(fallbackConversationStarted, false)
|
||||
assert.deepEqual(continuationPayload, {
|
||||
claim_id: 'draft-travel-1',
|
||||
claim_no: 'RE-202606-010',
|
||||
original_message: '我要报销'
|
||||
})
|
||||
})
|
||||
|
||||
test('workbench standalone draft action asks before creating a new reimbursement draft', () => {
|
||||
let standalonePrompt = null
|
||||
let fallbackConversationStarted = false
|
||||
const router = useWorkbenchAiActionRouter({
|
||||
aiExpenseDraft: { value: null },
|
||||
applicationFlow: {
|
||||
isInlineSuggestedActionDisabled: () => false,
|
||||
executeInlineApplicationPreviewAction: () => {}
|
||||
},
|
||||
assistantDraft: { value: '' },
|
||||
attachmentFlow: {
|
||||
confirmAiAttachmentAssociation: () => {}
|
||||
},
|
||||
emit: () => {},
|
||||
expenseFlow: {
|
||||
linkAiExpenseApplication: () => {},
|
||||
promptAiReimbursementDraftContinuation: () => {},
|
||||
promptStandaloneReimbursementDraftCreation: (sourceText, label) => {
|
||||
standalonePrompt = { sourceText, label }
|
||||
},
|
||||
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||
startAiApplicationPreviewFromAction: () => {},
|
||||
startAiExpenseDraft: () => {},
|
||||
startAiReimbursementAssociationGate: () => {}
|
||||
},
|
||||
focusAiModeInput: () => {},
|
||||
hasInlineAttachmentOcrDetails: () => false,
|
||||
resolveLatestInlineUserPrompt: () => '',
|
||||
selectedFiles: { value: [] },
|
||||
startInlineConversation: () => {
|
||||
fallbackConversationStarted = true
|
||||
},
|
||||
toast: () => {},
|
||||
toggleInlineAttachmentOcrDetails: () => {}
|
||||
})
|
||||
|
||||
router.handleInlineSuggestedAction({
|
||||
label: '独立新建报销单',
|
||||
action_type: CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
payload: {
|
||||
original_message: '我要报销'
|
||||
}
|
||||
})
|
||||
|
||||
assert.equal(fallbackConversationStarted, false)
|
||||
assert.deepEqual(standalonePrompt, {
|
||||
sourceText: '我要报销',
|
||||
label: '独立新建报销单'
|
||||
})
|
||||
})
|
||||
|
||||
46
web/tests/workbench-ai-application-result-card.test.mjs
Normal file
46
web/tests/workbench-ai-application-result-card.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildInlineApplicationDetailAction,
|
||||
buildInlineApplicationPreviewActionResultText
|
||||
} from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT
|
||||
} from '../src/services/aiApplicationPreviewActions.js'
|
||||
|
||||
const draftPayload = {
|
||||
claim_no: 'AUKSNUCFD',
|
||||
claim_id: 'claim-1001',
|
||||
approval_stage: '直属领导审批',
|
||||
start_date: '2026-02-20',
|
||||
location: '上海辅助国网仿生产服务器',
|
||||
reason: '差旅费用申请'
|
||||
}
|
||||
|
||||
test('application result card stays display-only while the detail shortcut keeps navigation', () => {
|
||||
const resultText = buildInlineApplicationPreviewActionResultText(AI_APPLICATION_ACTION_SUBMIT, {
|
||||
result: { draft_payload: draftPayload }
|
||||
})
|
||||
const [detailAction] = buildInlineApplicationDetailAction(draftPayload)
|
||||
|
||||
assert.match(resultText, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
|
||||
assert.doesNotMatch(resultText, /\| 操作 \|/)
|
||||
assert.doesNotMatch(resultText, /\[查看\]/)
|
||||
assert.doesNotMatch(resultText, /点击卡片.*操作.*查看/)
|
||||
assert.equal(detailAction?.label, '查看单据详情')
|
||||
assert.equal(detailAction?.action_type, 'open_application_detail')
|
||||
assert.equal(detailAction?.payload?.claim_no, 'AUKSNUCFD')
|
||||
})
|
||||
|
||||
test('saved draft result also avoids the duplicate in-card view guidance', () => {
|
||||
const resultText = buildInlineApplicationPreviewActionResultText(AI_APPLICATION_ACTION_SAVE_DRAFT, {
|
||||
result: { draft_payload: draftPayload }
|
||||
})
|
||||
|
||||
assert.match(resultText, /申请草稿已保存/)
|
||||
assert.doesNotMatch(resultText, /\| 操作 \|/)
|
||||
assert.doesNotMatch(resultText, /\[查看\]/)
|
||||
assert.doesNotMatch(resultText, /点击卡片.*操作.*查看/)
|
||||
})
|
||||
@@ -9,9 +9,13 @@ function readSource(path) {
|
||||
|
||||
const aiModeComponent = readSource('../src/components/business/PersonalWorkbenchAiMode.vue')
|
||||
const aiModeTemplate = readSource('../src/components/business/PersonalWorkbenchAiMode.template.html')
|
||||
const aiModeStyles = readSource('../src/assets/styles/components/personal-workbench-ai-mode.css')
|
||||
const composerComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiComposer.vue')
|
||||
const fileStripComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFileStrip.vue')
|
||||
const filePreviewComponent = readSource('../src/components/business/workbench-ai/WorkbenchAiFilePreviewDialog.vue')
|
||||
const filePreviewStyles = readSource('../src/assets/styles/components/workbench-ai-file-preview-dialog.css')
|
||||
const aiModeRuntime = readSource('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js')
|
||||
const filePreviewRuntime = readSource('../src/composables/workbenchAiMode/useWorkbenchAiFilePreview.js')
|
||||
|
||||
function countOccurrences(source, pattern) {
|
||||
return source.match(pattern)?.length || 0
|
||||
@@ -44,3 +48,57 @@ test('shared workbench file strip preserves OCR status badges', () => {
|
||||
assert.match(fileStripComponent, /mdi mdi-text-recognition/)
|
||||
assert.match(fileStripComponent, /:title="file\.ocrState\.title \|\| file\.ocrState\.label"/)
|
||||
})
|
||||
|
||||
test('AI mode primes attachment OCR synchronously after file selection', () => {
|
||||
assert.match(
|
||||
filePreviewRuntime,
|
||||
/watch\(\s*selectedFiles,\s*\(files(?:,\s*previousFiles\s*=\s*\[\])?\)\s*=>\s*\{[\s\S]*attachmentFlow\.primeAiModeReceiptContext\(files\)[\s\S]*\},\s*\{\s*flush:\s*'sync'\s*\}\s*\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('AI mode keeps conversation anchored above selected attachments', () => {
|
||||
assert.match(
|
||||
filePreviewRuntime,
|
||||
/watch\(\s*selectedFiles,\s*\(files,\s*previousFiles\s*=\s*\[\]\)\s*=>\s*\{[\s\S]*const fileCountChanged = files\.length !== previousFiles\.length[\s\S]*scrollInlineConversationToBottom\(\{\s*force:\s*true\s*\}\)[\s\S]*\},\s*\{\s*flush:\s*'sync'\s*\}\s*\)/
|
||||
)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-conversation-bottom\s*\{[\s\S]*gap:\s*14px;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-thread\s*\{[\s\S]*scroll-padding-bottom:\s*42px;/)
|
||||
})
|
||||
|
||||
test('AI mode lays selected attachments in a horizontal scroll strip', () => {
|
||||
assert.match(aiModeStyles, /\.workbench-ai-file-strip\s*\{[\s\S]*flex-wrap:\s*nowrap;[\s\S]*overflow-x:\s*auto;[\s\S]*overflow-y:\s*hidden;/)
|
||||
assert.match(aiModeStyles, /\.workbench-ai-file-card\s*\{[\s\S]*flex:\s*0 0 312px;/)
|
||||
assert.match(fileStripComponent, /role="button"/)
|
||||
assert.match(fileStripComponent, /@click="runtime\.openAiModeFilePreview\(file\.key\)"/)
|
||||
assert.match(fileStripComponent, /@click\.stop="runtime\.removeAiModeFile\(file\.key\)"/)
|
||||
})
|
||||
|
||||
test('AI mode attachment preview opens a split source and recognition dialog', () => {
|
||||
assert.match(aiModeComponent, /import WorkbenchAiFilePreviewDialog from '\.\/workbench-ai\/WorkbenchAiFilePreviewDialog\.vue'/)
|
||||
assert.match(aiModeTemplate, /<WorkbenchAiFilePreviewDialog :runtime="workbenchAiRuntime" \/>/)
|
||||
assert.match(aiModeRuntime, /useWorkbenchAiFilePreview\(/)
|
||||
assert.match(aiModeRuntime, /\.\.\.filePreview,/)
|
||||
assert.match(filePreviewRuntime, /URL\.createObjectURL\(rawFile\)/)
|
||||
assert.match(filePreviewRuntime, /attachmentFlow\.resolveAiModeReceiptRecognitionState\(rawFile\)/)
|
||||
assert.match(filePreviewComponent, /class="workbench-ai-file-preview-source"/)
|
||||
assert.match(filePreviewComponent, /class="workbench-ai-file-preview-insight"/)
|
||||
assert.match(filePreviewComponent, /<img[\s\S]*v-if="preview\.sourceKind === 'image'"/)
|
||||
assert.match(filePreviewComponent, /<iframe[\s\S]*v-else-if="preview\.sourceKind === 'pdf'"/)
|
||||
assert.match(filePreviewComponent, /v-for="field in preview\.ocrFields"/)
|
||||
})
|
||||
|
||||
test('AI mode attachment preview centers inside the main content area beside the sidebar', () => {
|
||||
assert.match(filePreviewStyles, /--workbench-ai-preview-sidebar-offset:\s*var\(--sidebar-expanded-width,\s*304px\);/)
|
||||
assert.match(
|
||||
filePreviewStyles,
|
||||
/\.workbench-ai-file-preview-mask\s*\{[\s\S]*grid-template-columns:\s*var\(--workbench-ai-preview-sidebar-offset\) minmax\(0,\s*1fr\);/
|
||||
)
|
||||
assert.match(
|
||||
filePreviewStyles,
|
||||
/\.workbench-ai-file-preview-dialog\s*\{[\s\S]*grid-column:\s*2;[\s\S]*justify-self:\s*center;/
|
||||
)
|
||||
assert.match(
|
||||
filePreviewStyles,
|
||||
/@media \(max-width:\s*900px\)\s*\{[\s\S]*--workbench-ai-preview-sidebar-offset:\s*0px;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\);/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -195,14 +195,14 @@ test('AI mode formats saved application draft as a detail table without continui
|
||||
assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/)
|
||||
assert.match(aiMode, /submitted:\s*'审批中'/)
|
||||
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
|
||||
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
|
||||
assert.match(aiMode, /\[查看\]\(\$\{href\}\)/)
|
||||
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
|
||||
assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
|
||||
assert.doesNotMatch(aiMode, /\[查看\]\(\$\{href\}\)/)
|
||||
assert.match(aiMode, /dateLabel:\s*rangeText \|\| dateText \|\| resolveBodyField\(\['时间', '日期', '申请时间'\]\) \|\| '待补充'/)
|
||||
assert.match(aiMode, /locationLabel:[\s\S]*resolveBodyField\(\['地点', '目的地'\]\) \|\| '待补充'/)
|
||||
assert.match(aiMode, /reasonLabel:[\s\S]*resolveBodyField\(\['事由', '事件', '申请事由'\]\) \|\| '待补充'/)
|
||||
assert.match(aiMode, /buildInlineApplicationActionDetailHref\(info\)/)
|
||||
assert.match(aiMode, /params\.set\('claim_id', claimId\)/)
|
||||
assert.match(aiMode, /params\.set\('claim_no', claimNo\)/)
|
||||
assert.match(aiMode, /function buildInlineApplicationDetailAction\(draftPayload = \{\}\)/)
|
||||
assert.match(aiMode, /action_type:\s*'open_application_detail'/)
|
||||
|
||||
const resultStart = aiMode.indexOf('function buildInlineApplicationPreviewActionResultText')
|
||||
const resultEnd = aiMode.indexOf('\nfunction buildInlineApplicationDetailAction', resultStart)
|
||||
@@ -227,7 +227,7 @@ test('AI mode formats saved application draft as a detail table without continui
|
||||
executeBlock,
|
||||
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
|
||||
)
|
||||
assert.match(executeBlock, /suggestedActions:\s*isSubmit\s*\?\s*buildInlineApplicationDetailAction\(draftPayload\)\s*:\s*\[\]/)
|
||||
assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/)
|
||||
})
|
||||
|
||||
test('AI mode locks application preview actions while estimate refresh is pending', () => {
|
||||
|
||||
@@ -244,9 +244,10 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /buildFileIdentity,[\s\S]*collectReceiptFiles[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||
assert.match(aiModeSurface, /MAX_ATTACHMENTS,[\s\S]*mergeFilesWithLimit[\s\S]*travelReimbursementAttachmentModel\.js/)
|
||||
assert.match(aiModeSurface, /import \* as aiAttachmentAssociationModel from '\.\.\/\.\.\/utils\/aiAttachmentAssociationModel\.js'/)
|
||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch/)
|
||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.buildAiAttachmentAssociationResultMessage/)
|
||||
assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft/)
|
||||
assert.match(aiModeSurface, /createAttachmentAssociationJob[\s\S]*services\/attachmentAssociationJobs\.js/)
|
||||
assert.match(aiModeSurface, /fetchAttachmentAssociationJob[\s\S]*services\/attachmentAssociationJobs\.js/)
|
||||
assert.match(aiModeSurface, /import \{ recognizeOcrFiles \} from '\.\.\/\.\.\/services\/ocr\.js'/)
|
||||
assert.match(aiModeSurface, /const AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION = 'confirm_ai_attachment_association'/)
|
||||
assert.match(aiModeSurface, /const AI_ATTACHMENT_OCR_DETAIL_ACTION = 'show_ai_attachment_ocr_details'/)
|
||||
@@ -269,10 +270,16 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /resolveAiAttachmentAssociationClaimNo\(actionPayload\)/)
|
||||
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION\)/)
|
||||
assert.match(aiModeSurface, /const collected = await collectAiModeReceiptContext\(files\)/)
|
||||
assert.match(aiModeSurface, /const claims = extractExpenseClaimItems\(claimsPayload\)/)
|
||||
assert.match(aiModeSurface, /aiAttachmentAssociationModel\.resolveAiAttachmentAssociationMatch\(claims, collected\.ocrDocuments\)/)
|
||||
assert.match(aiModeSurface, /aiAttachmentAssociationRuntime\.set\(associationId/)
|
||||
assert.match(aiModeSurface, /attachmentOcrDetails,\s*[\s\S]*includeOcrDetails: Boolean\(attachmentOcrDetails\)/)
|
||||
assert.match(aiModeSurface, /function extractReceiptIdsFromOcrDocuments\(documents = \[\]\)/)
|
||||
assert.match(aiModeSurface, /const receiptIds = attachmentJobFlow\.extractReceiptIdsFromOcrDocuments\(collected\.ocrDocuments\)/)
|
||||
assert.match(aiModeSurface, /await createAttachmentAssociationJob\(\{[\s\S]*receipt_ids: receiptIds,[\s\S]*conversation_id: conversationId\?\.value/)
|
||||
assert.match(aiModeSurface, /attachmentAssociationJob: job/)
|
||||
assert.match(aiModeSurface, /async function pollJob\(/)
|
||||
assert.match(aiModeSurface, /fetchAttachmentAssociationJob\(normalizedJobId\)/)
|
||||
assert.match(aiModeSurface, /function resumePendingJobs\(\)/)
|
||||
assert.match(aiModeSurface, /resumePendingAiAttachmentAssociationJobs: attachmentJobFlow\.resumePendingJobs/)
|
||||
assert.match(aiModeSurface, /attachmentFlow\.resumePendingAiAttachmentAssociationJobs\(\)/)
|
||||
assert.match(aiModeSurface, /attachmentAssociationJob: normalizeInlineAttachmentAssociationJob/)
|
||||
assert.match(aiModeSurface, /async function confirmAiAttachmentAssociation\(actionPayload = \{\}, sourceMessage = null\)/)
|
||||
assert.match(aiModeSurface, /syncExpenseClaimFilesToDraft\(\{[\s\S]*fetchExpenseClaimDetail,[\s\S]*createExpenseClaimItem,[\s\S]*uploadExpenseClaimItemAttachment/)
|
||||
assert.match(aiModeSurface, /if \(actionType === AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION\)/)
|
||||
@@ -388,7 +395,8 @@ test('AI mode screen follows the approved reference structure', () => {
|
||||
assert.match(aiModeSurface, /const hasServerStreamedContent = Boolean\(String\(pendingMessage\.content \|\| ''\)\.trim\(\)\)/)
|
||||
assert.match(aiModeSurface, /if \(!hasServerStreamedContent\) \{[\s\S]*await streamInlineAssistantContent\(pendingMessage\.id, finalMessageText\)[\s\S]*\}/)
|
||||
assert.match(aiModeSurface, /if \(actionType === AI_APPLICATION_ACTION_SUBMIT\) \{[\s\S]*buildInlineApplicationResultTable\(draftPayload/)
|
||||
assert.match(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
|
||||
assert.match(aiModeSurface, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
|
||||
assert.doesNotMatch(aiModeSurface, /需要查看完整详情时,请点击卡片“操作”行的“查看”进入单据详情。/)
|
||||
assert.match(aiModeSurface, /function buildInlineApplicationActionFailureText\(error, isSubmit\)/)
|
||||
assert.match(aiModeSurface, /我已保留当前申请核对表/)
|
||||
assert.match(aiModeSurface, /applicationPreview:\s*targetMessage\.applicationPreview/)
|
||||
@@ -549,12 +557,25 @@ test('AI mode normal assistant requests include OCR context for uploaded receipt
|
||||
assert.match(aiModeSurface, /const aiModeReceiptContextCache = new Map\(\)/)
|
||||
assert.match(aiModeSurface, /const aiModeReceiptRecognitionState = reactive\(\{\}\)/)
|
||||
assert.match(aiModeSurface, /function resolveAiModeReceiptRecognitionState\(file\)/)
|
||||
assert.match(aiModeSurface, /function hasPendingAiModeReceiptRecognition\(files = \[\]\)/)
|
||||
assert.match(aiModeSurface, /function hasFailedAiModeReceiptRecognition\(files = \[\]\)/)
|
||||
assert.match(aiModeSurface, /resolveAiModeReceiptRecognitionState\(selectedFiles\.value\[index\]\)/)
|
||||
assert.match(aiModeSurface, /status:\s*'recognizing'[\s\S]*label:\s*'智能录入识别中'/)
|
||||
assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `已识别票据/)
|
||||
assert.match(aiModeSurface, /status:\s*'recognized'[\s\S]*label:\s*detail \? `当前会话已识别/)
|
||||
assert.match(aiModeSurface, /本状态不代表票据夹已有记录/)
|
||||
assert.match(aiModeSurface, /status:\s*'failed'[\s\S]*label:\s*'识别失败'/)
|
||||
assert.match(aiModeSurface, /const isAiModeReceiptRecognitionPending = computed\(\(\) => attachmentFlow\.hasPendingAiModeReceiptRecognition\(selectedFiles\.value\)\)/)
|
||||
assert.match(aiModeSurface, /const hasAiModeReceiptRecognitionFailure = computed\(\(\) => attachmentFlow\.hasFailedAiModeReceiptRecognition\(selectedFiles\.value\)\)/)
|
||||
assert.match(aiModeSurface, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/)
|
||||
assert.match(aiModeSurface, /!hasAiModeReceiptRecognitionFailure\.value[\s\S]*Boolean\(assistantDraft\.value\.trim\(\)\)/)
|
||||
assert.match(aiModeSurface, /function resolveAiModeInputLockMessage\(\) \{[\s\S]*附件识别中,请稍等/)
|
||||
assert.match(aiModeSurface, /hasAiModeReceiptRecognitionFailure\.value[\s\S]*请先移除识别失败的附件或重新上传/)
|
||||
assert.match(aiModeSurface, /:placeholder="runtime\.isAiModeInputLocked \? runtime\.aiModeInputLockMessage : placeholder"/)
|
||||
assert.match(aiModeSurface, /function primeAiModeReceiptContext\(files = \[\]\)/)
|
||||
assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\]\)/)
|
||||
assert.match(aiModeSurface, /function startAiModeReceiptRecognition\(files = \[\], options = \{\}\)/)
|
||||
assert.match(aiModeSurface, /const forceRefresh = Boolean\(options\.forceRefresh\)/)
|
||||
assert.match(aiModeSurface, /if \(!forceRefresh && cached\?\.status === 'resolved'\) \{/)
|
||||
assert.match(aiModeSurface, /startAiModeReceiptRecognition\(files, \{ forceRefresh: true \}\)/)
|
||||
assert.match(aiModeSurface, /function buildAiModeReceiptContextCacheKey\(ocrFiles = \[\]\)/)
|
||||
assert.match(aiModeSurface, /applyAiModeReceiptRecognitionResult\(ocrFiles, context\)/)
|
||||
assert.match(aiModeSurface, /buildFileIdentity\(file\)/)
|
||||
|
||||
@@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { useWorkbenchAiExpenseFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js'
|
||||
import {
|
||||
buildLinkedDraftRunningText,
|
||||
useWorkbenchAiExpenseFlow
|
||||
} from '../src/composables/workbenchAiMode/useWorkbenchAiExpenseFlow.js'
|
||||
import {
|
||||
CONTINUE_REIMBURSEMENT_DRAFT_ACTION,
|
||||
CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION
|
||||
} from '../src/views/scripts/travelReimbursementAssociationGateModel.js'
|
||||
|
||||
const personalWorkbenchAiMode = readFileSync(
|
||||
join(process.cwd(), 'web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js'),
|
||||
@@ -40,7 +47,11 @@ function buildFlow(options = {}) {
|
||||
conversationStarted,
|
||||
createInlineMessage,
|
||||
currentUser: { value: options.currentUser || { name: '张小青', username: 'xiaoqing.zhang' } },
|
||||
createLinkedReimbursementDraftJobForAi: options.createLinkedReimbursementDraftJobForAi,
|
||||
fetchExpenseClaimsForAi: options.fetchExpenseClaimsForAi,
|
||||
fetchLinkedReimbursementDraftJobForAi: options.fetchLinkedReimbursementDraftJobForAi,
|
||||
linkedDraftJobPollIntervalMs: options.linkedDraftJobPollIntervalMs ?? 0,
|
||||
linkedDraftJobMaxPolls: options.linkedDraftJobMaxPolls ?? 2,
|
||||
runOrchestratorForAi: options.runOrchestratorForAi,
|
||||
associationQueryTimeoutMs: options.associationQueryTimeoutMs,
|
||||
persistCurrentConversation: () => {
|
||||
@@ -61,6 +72,18 @@ function buildFlow(options = {}) {
|
||||
|
||||
const conversationStarted = { value: false }
|
||||
|
||||
test('linked reimbursement draft running text avoids duplicate status wording', () => {
|
||||
const content = buildLinkedDraftRunningText(
|
||||
{ message: '正在后台生成报销草稿...' },
|
||||
'AVF9ST8TT'
|
||||
)
|
||||
|
||||
const repeated = content.match(/正在后台生成报销草稿/g) || []
|
||||
assert.equal(repeated.length, 1)
|
||||
assert.doesNotMatch(content, /处理状态:\s*正在后台生成报销草稿/)
|
||||
assert.match(content, /回来后我会继续查询任务结果/)
|
||||
})
|
||||
|
||||
test('reimbursement intent checks drafts before recommending approved application documents', async () => {
|
||||
conversationStarted.value = false
|
||||
let queried = 0
|
||||
@@ -135,7 +158,7 @@ test('reimbursement intent stops at existing reimbursement drafts before applica
|
||||
reason: '北京客户现场实施报销',
|
||||
location: '北京',
|
||||
status: 'draft',
|
||||
amount: 650,
|
||||
amount: '0.00',
|
||||
created_at: '2026-06-23T10:00:00Z'
|
||||
},
|
||||
{
|
||||
@@ -160,10 +183,20 @@ test('reimbursement intent stops at existing reimbursement drafts before applica
|
||||
assert.match(assistantMessage.content, /先检查.*报销草稿/)
|
||||
assert.match(assistantMessage.content, /查到 1 个可继续的报销草稿/)
|
||||
assert.match(assistantMessage.content, /RE-202606-010/)
|
||||
assert.match(assistantMessage.content, /待确认/)
|
||||
assert.doesNotMatch(assistantMessage.content, />0\.00</)
|
||||
assert.match(assistantMessage.content, /2026-06-23 10:00/)
|
||||
assert.doesNotMatch(assistantMessage.content, /T10:00:00Z/)
|
||||
assert.doesNotMatch(assistantMessage.content, /AP-202606-001/)
|
||||
assert.match(assistantMessage.content, /下方三个按钮/)
|
||||
assert.doesNotMatch(assistantMessage.content, /跳过草稿后再关联申请单/)
|
||||
assert.equal(assistantMessage.suggestedActions.length, 3)
|
||||
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
|
||||
assert.match(assistantMessage.suggestedActions[0].label, /继续草稿/)
|
||||
assert.equal(assistantMessage.suggestedActions.at(-1).action_type, 'skip_reimbursement_draft_check')
|
||||
assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
|
||||
assert.equal(assistantMessage.suggestedActions[1].action_type, CONTINUE_REIMBURSEMENT_DRAFT_ACTION)
|
||||
assert.equal(assistantMessage.suggestedActions[1].label, '继续关联草稿 RE-202606-010')
|
||||
assert.equal(assistantMessage.suggestedActions[2].action_type, CREATE_STANDALONE_REIMBURSEMENT_DRAFT_ACTION)
|
||||
assert.equal(assistantMessage.suggestedActions[2].label, '独立新建报销单')
|
||||
})
|
||||
|
||||
test('reimbursement association gate shows thinking before querying and renders application cards', async () => {
|
||||
@@ -274,8 +307,33 @@ test('reimbursement association gate matches short username with returned employ
|
||||
test('linked application selection can create reimbursement draft from association gate', async () => {
|
||||
conversationStarted.value = false
|
||||
const orchestratorCalls = []
|
||||
const createJobCalls = []
|
||||
const fetchJobCalls = []
|
||||
const { aiExpenseDraft, conversationMessages, flow } = buildFlow({
|
||||
fetchExpenseClaimsForAi: async () => ({ items: [] }),
|
||||
createLinkedReimbursementDraftJobForAi: async (payload) => {
|
||||
createJobCalls.push(payload)
|
||||
return {
|
||||
job_id: 'linked-draft-job-1',
|
||||
status: 'queued',
|
||||
message: '已创建后台生成任务。'
|
||||
}
|
||||
},
|
||||
fetchLinkedReimbursementDraftJobForAi: async (jobId) => {
|
||||
fetchJobCalls.push(jobId)
|
||||
return {
|
||||
job_id: jobId,
|
||||
status: 'succeeded',
|
||||
message: '报销草稿已生成。',
|
||||
draft_payload: {
|
||||
claim_id: 'draft-linked-1',
|
||||
claim_no: 'RE-202606-009',
|
||||
status: 'draft',
|
||||
expense_type: 'travel',
|
||||
reason: '北京客户现场实施'
|
||||
}
|
||||
}
|
||||
},
|
||||
runOrchestratorForAi: async (payload, options) => {
|
||||
orchestratorCalls.push({ payload, options })
|
||||
return {
|
||||
@@ -303,16 +361,93 @@ test('linked application selection can create reimbursement draft from associati
|
||||
application_amount_label: '1,650元'
|
||||
})
|
||||
|
||||
assert.equal(orchestratorCalls.length, 1)
|
||||
assert.equal(orchestratorCalls[0].payload.context_json.review_action, 'save_draft')
|
||||
assert.equal(orchestratorCalls[0].payload.context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
|
||||
assert.equal(orchestratorCalls[0].payload.context_json.review_form_values.application_claim_no, 'AP-202606-001')
|
||||
assert.equal(orchestratorCalls.length, 0)
|
||||
assert.equal(createJobCalls.length, 1)
|
||||
assert.equal(createJobCalls[0].context_json.review_action, 'save_draft')
|
||||
assert.equal(createJobCalls[0].context_json.expense_scene_selection.application_claim_no, 'AP-202606-001')
|
||||
assert.equal(createJobCalls[0].context_json.review_form_values.application_claim_no, 'AP-202606-001')
|
||||
assert.deepEqual(fetchJobCalls, ['linked-draft-job-1'])
|
||||
assert.equal(aiExpenseDraft.value, null)
|
||||
assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-009 已生成/)
|
||||
assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-009')
|
||||
assert.equal(conversationMessages.value.at(-1).suggestedActions[0].action_type, 'open_application_detail')
|
||||
})
|
||||
|
||||
test('linked reimbursement draft job resumes from pending history message', async () => {
|
||||
conversationStarted.value = true
|
||||
const fetchJobCalls = []
|
||||
const { conversationMessages, flow } = buildFlow({
|
||||
fetchLinkedReimbursementDraftJobForAi: async (jobId) => {
|
||||
fetchJobCalls.push(jobId)
|
||||
return {
|
||||
job_id: jobId,
|
||||
status: 'succeeded',
|
||||
message: '报销草稿已生成。',
|
||||
draft_payload: {
|
||||
claim_id: 'draft-resumed-1',
|
||||
claim_no: 'RE-202606-011',
|
||||
status: 'draft',
|
||||
expense_type: 'travel'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
conversationMessages.value.push(createInlineMessage('assistant', '已关联申请单 AP-202606-001,正在后台生成报销草稿...', {
|
||||
id: 'pending-linked-draft-message',
|
||||
pending: true,
|
||||
linkedReimbursementDraftJob: {
|
||||
jobId: 'linked-draft-job-resume',
|
||||
status: 'queued',
|
||||
applicationClaimNo: 'AP-202606-001'
|
||||
}
|
||||
}))
|
||||
|
||||
flow.resumePendingLinkedReimbursementDraftJobs()
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
|
||||
assert.deepEqual(fetchJobCalls, ['linked-draft-job-resume'])
|
||||
assert.match(conversationMessages.value.at(-1).content, /报销草稿 RE-202606-011 已生成/)
|
||||
assert.match(conversationMessages.value.at(-1).content, /AP-202606-001/)
|
||||
assert.equal(conversationMessages.value.at(-1).pending, false)
|
||||
assert.equal(conversationMessages.value.at(-1).draftPayload.claim_no, 'RE-202606-011')
|
||||
})
|
||||
|
||||
test('continuing an existing reimbursement draft prompts for attachments or description', () => {
|
||||
conversationStarted.value = false
|
||||
const { conversationMessages, flow } = buildFlow()
|
||||
|
||||
flow.promptAiReimbursementDraftContinuation({
|
||||
claim_id: 'draft-travel-1',
|
||||
claim_no: 'RE-202606-010',
|
||||
original_message: '我要报销'
|
||||
})
|
||||
|
||||
assert.equal(conversationMessages.value.at(-2).role, 'user')
|
||||
assert.equal(conversationMessages.value.at(-2).content, '继续关联草稿 RE-202606-010')
|
||||
const assistantMessage = conversationMessages.value.at(-1)
|
||||
assert.equal(assistantMessage.role, 'assistant')
|
||||
assert.match(assistantMessage.content, /请上传相关的附件/)
|
||||
assert.match(assistantMessage.content, /补充说明/)
|
||||
assert.equal(assistantMessage.suggestedActions[0].action_type, 'open_application_detail')
|
||||
assert.equal(assistantMessage.suggestedActions[0].label, '查看草稿 RE-202606-010')
|
||||
})
|
||||
|
||||
test('standalone reimbursement draft branch asks before creating a new draft', () => {
|
||||
conversationStarted.value = false
|
||||
const { conversationMessages, flow } = buildFlow()
|
||||
|
||||
flow.promptStandaloneReimbursementDraftCreation('我要报销', '独立新建报销单')
|
||||
|
||||
assert.equal(conversationMessages.value.at(-2).role, 'user')
|
||||
assert.equal(conversationMessages.value.at(-2).content, '独立新建报销单')
|
||||
const assistantMessage = conversationMessages.value.at(-1)
|
||||
assert.equal(assistantMessage.role, 'assistant')
|
||||
assert.match(assistantMessage.content, /是否新建草稿单据/)
|
||||
assert.equal(assistantMessage.suggestedActions[0].label, '新建草稿单据')
|
||||
assert.equal(assistantMessage.suggestedActions[0].action_type, 'skip_required_application_link')
|
||||
assert.equal(assistantMessage.suggestedActions[1].label, '暂不新建')
|
||||
})
|
||||
|
||||
test('personal workbench routes reimbursement creation intent to association gate before steward', () => {
|
||||
assert.match(
|
||||
personalWorkbenchAiMode,
|
||||
|
||||
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useWorkbenchComposerDate } from '../src/composables/useWorkbenchComposerDate.js'
|
||||
import {
|
||||
buildWorkbenchDateLabel,
|
||||
canApplyWorkbenchDateSelection,
|
||||
@@ -76,3 +78,34 @@ test('workbench date helper builds labels and inserts them into draft text', ()
|
||||
)
|
||||
assert.equal(canApplyWorkbenchDateSelection({ mode: 'range', rangeStartDate: '2026-06-01', rangeEndDate: '2026-05-31' }), false)
|
||||
})
|
||||
|
||||
test('workbench range end date changes keep the picker open until the user confirms', () => {
|
||||
const draft = ref('')
|
||||
let focusCount = 0
|
||||
const dateRuntime = useWorkbenchComposerDate({
|
||||
draft,
|
||||
focusInput: () => {
|
||||
focusCount += 1
|
||||
}
|
||||
})
|
||||
|
||||
dateRuntime.workbenchDatePickerOpen.value = true
|
||||
dateRuntime.workbenchDateMode.value = 'range'
|
||||
dateRuntime.workbenchRangeStartDate.value = '2026-02-20'
|
||||
dateRuntime.workbenchRangeEndDate.value = '2026-03-23'
|
||||
|
||||
dateRuntime.handleWorkbenchDateInputChange('range-end')
|
||||
|
||||
assert.equal(dateRuntime.workbenchDatePickerOpen.value, true)
|
||||
assert.equal(dateRuntime.workbenchDateTagLabel.value, '')
|
||||
assert.equal(draft.value, '')
|
||||
assert.equal(focusCount, 0)
|
||||
|
||||
dateRuntime.applyWorkbenchDateSelection()
|
||||
|
||||
assert.equal(dateRuntime.workbenchDatePickerOpen.value, false)
|
||||
assert.equal(dateRuntime.workbenchDateTagLabel.value, '2026-02-20 至 2026-03-23')
|
||||
assert.equal(draft.value, '')
|
||||
assert.equal(dateRuntime.buildWorkbenchPromptText(), '2026-02-20 至 2026-03-23')
|
||||
assert.equal(focusCount, 1)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user