feat(platform): close AI expense value loop

Add tenant-safe value, telemetry, connector, commercial, and production-readiness foundations.
This commit is contained in:
caoxiaozhu
2026-07-17 14:14:08 +08:00
parent 242d68c36f
commit 787bc3a481
507 changed files with 82072 additions and 6344 deletions

View File

@@ -1,15 +1,28 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { readFileSync, readdirSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { buildExpenseSceneSelectionActions } from '../src/utils/expenseAssistantActions.js'
import { buildExpenseSceneSelectionMessage } from '../src/views/scripts/travelReimbursementConversationModel.js'
import { normalizeInlineApplicationStatusLabel } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
const aiMode = readFileSync(
fileURLToPath(new URL('../src/components/business/PersonalWorkbenchAiMode.vue', import.meta.url)),
'utf8'
)
function readSource(path) {
return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
}
const aiModeRuntimeDir = fileURLToPath(new URL('../src/composables/workbenchAiMode/', import.meta.url))
const applicationPreviewFlow = readSource('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js')
const applicationPreviewModel = readSource('../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js')
const aiMode = [
readSource('../src/components/business/PersonalWorkbenchAiMode.vue'),
readSource('../src/components/business/PersonalWorkbenchAiMode.template.html'),
readSource('../src/utils/aiDocumentQueryModel.js'),
...readdirSync(aiModeRuntimeDir)
.filter((file) => file.endsWith('.js'))
.sort()
.map((file) => readSource(`../src/composables/workbenchAiMode/${file}`))
].join('\n')
test('expense scene selection message asks for type first and mentions application gate', () => {
const text = buildExpenseSceneSelectionMessage('帮我发起一笔报销,并检查需要准备哪些票据材料。')
@@ -32,10 +45,10 @@ test('expense scene actions mark travel and meal as requiring application', () =
assert.equal(transport.payload.next_session_type, 'expense')
})
test('AI mode quick reimbursement card opens scene selection before steward plan', () => {
test('AI mode quick reimbursement card enters the reimbursement association gate before steward plan', () => {
assert.match(
aiMode,
/function runAiModeAction\(item\) {[\s\S]{0,220}pushInlineExpenseSceneSelectionPrompt\(item\.prompt, item\.label\)/
/function runAiModeAction\(item\) {[\s\S]{0,300}expenseFlow\.startAiReimbursementAssociationGate\(item\.prompt, item\.label\)/
)
})
@@ -53,27 +66,28 @@ test('AI mode offers an inline application shortcut when no candidate applicatio
assert.match(aiMode, /buildLocalApplicationPreviewMessage/)
assert.match(aiMode, /refreshApplicationPreviewEstimate/)
assert.match(aiMode, /applicationPreview:\s*preview/)
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(preview\)/)
assert.match(aiMode, /suggestedActions:\s*buildInlineApplicationPreviewSuggestedActions\(registeredPreview\)/)
assert.doesNotMatch(aiMode, /function startAiApplicationDraft/)
assert.doesNotMatch(aiMode, /buildAiApplicationStepPrompt/)
})
test('AI mode steward reimbursement action opens expense scene selection locally', () => {
test('AI mode steward reimbursement action enters the association gate while standalone flow keeps scene selection', () => {
assert.match(aiMode, /buildExpenseSceneSelectionMessage/)
assert.match(aiMode, /buildExpenseSceneSelectionActions/)
assert.match(aiMode, /SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /function pushInlineExpenseSceneSelectionPrompt/)
assert.match(aiMode, /payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)/)
assert.match(aiMode, /action\?\.payload\?\.session_type[\s\S]*SESSION_TYPE_EXPENSE/)
assert.match(aiMode, /expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)/)
assert.match(
aiMode,
/SESSION_TYPE_EXPENSE[\s\S]{0,140}pushInlineExpenseSceneSelectionPrompt\(carryText, action\.label\)[\s\S]{0,40}return/
/SESSION_TYPE_EXPENSE && carryText === '我要报销'[\s\S]{0,160}expenseFlow\.startAiReimbursementAssociationGate\(carryText, action\.label\)[\s\S]{0,40}return/
)
assert.match(aiMode, /SKIP_REQUIRED_APPLICATION_LINK_ACTION[\s\S]{0,180}pushInlineExpenseSceneSelectionPrompt/)
})
test('AI mode attaches required application lookup result before steward planning', () => {
assert.match(aiMode, /async function attachAiRequiredApplicationGate\(planRequest, prompt\)/)
assert.match(aiMode, /fetchExpenseClaims\(\)/)
assert.match(aiMode, /fetchExpenseClaims\(REIMBURSEMENT_LIST_PREVIEW_PARAMS\)/)
assert.match(aiMode, /filterRequiredApplicationCandidates\(claims, 'travel', currentUser\.value \|\| \{\}\)/)
assert.match(aiMode, /required_application_gate/)
assert.match(aiMode, /await attachAiRequiredApplicationGate\(planRequest, prompt\)/)
@@ -95,7 +109,8 @@ test('AI mode handles document query prompts locally before steward planning', (
assert.match(aiMode, /查询业务单据接口/)
assert.match(aiMode, /组合筛选单据/)
assert.match(aiMode, /if \(await handleAiDocumentQueryIntent\(prompt, pendingMessage\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /emit\('open-document', buildAiDocumentDetailRequest\(detailReference\)\)/)
assert.match(aiMode, /await ensureAiDocumentDetailStillAvailable\(detailRequest\)/)
assert.match(aiMode, /emit\('open-document', detailRequest\)/)
})
test('AI mode asks for manual confirmation before generating application preview table', () => {
@@ -114,8 +129,8 @@ test('AI mode asks for manual confirmation before generating application preview
})
test('AI mode shows pending feedback before async application preview estimate refresh', () => {
const startPreviewFunction = aiMode.match(
/async function startAiApplicationPreview[\s\S]*?\n}\n\nfunction requestDeleteCurrentConversation/
const startPreviewFunction = applicationPreviewFlow.match(
/async function startAiApplicationPreview[\s\S]*?\n }\n\n return \{/
)?.[0] || ''
assert.match(startPreviewFunction, /const pendingMessage = createInlineMessage\(\s*'assistant',\s*'正在生成申请核对表/)
@@ -142,8 +157,8 @@ test('AI mode handles application preview save and submit through buttons or tex
assert.match(aiMode, /function executeInlineApplicationPreviewAction\(actionType, sourceMessage = null, options = \{\}\)/)
assert.match(aiMode, /function confirmInlineApplicationSubmit\(\)/)
assert.match(aiMode, /function cancelInlineApplicationSubmitConfirm\(\)/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
assert.match(aiMode, /if \(handleInlineApplicationPreviewTextAction\(cleanPrompt\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
assert.match(aiMode, /if \(applicationFlow\.handleInlineApplicationPreviewTextAction\(cleanPrompt, applicationPreviewEstimatePending\)\) \{[\s\S]*return[\s\S]*\}/)
assert.match(aiMode, /\[AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT\]\.includes\(actionType\)/)
assert.match(aiMode, /normalizedPreview\.readyToSubmit/)
assert.match(aiMode, /fetchExpenseClaims\(\{ page: 1, pageSize: 100 \}\)/)
@@ -155,22 +170,23 @@ test('AI mode handles application preview save and submit through buttons or tex
test('AI mode keeps missing application fields editable in the preview table without quick template action', () => {
assert.match(aiMode, /function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\)/)
assert.match(aiMode, /label:\s*'保存草稿'/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt\)/)
assert.match(aiMode, /function handleInlineApplicationPreviewTextAction\(prompt, applicationPreviewEstimatePending\)/)
assert.doesNotMatch(aiMode, /label:\s*'快速模板'/)
assert.doesNotMatch(aiMode, /action_type:\s*'prefill_composer'/)
assert.doesNotMatch(aiMode, /buildInlineApplicationPreviewTemplatePrefill/)
assert.doesNotMatch(aiMode, /applyInlineApplicationPreviewTemplateText/)
})
test('AI mode waits for submit confirmation before adding submit action to the conversation', () => {
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = aiMode.slice(executeStart, executeEnd)
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed)')
test('AI mode confirms a new direct submit before adding the action while allowing contextual saved-draft submit', () => {
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
const confirmGateIndex = executeBlock.indexOf('if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly)')
const requestConfirmIndex = executeBlock.indexOf('requestInlineApplicationSubmitConfirmation', confirmGateIndex)
const confirmedActionPushIndex = executeBlock.indexOf('pushInlineApplicationActionUserMessage(userText)', requestConfirmIndex)
assert.ok(confirmGateIndex >= 0, '直接提交应先进入确认分支')
assert.match(executeBlock, /const shouldSubmitSavedDraftDirectly = isSubmit/)
assert.ok(confirmGateIndex >= 0, '非上下文草稿提交应先进入确认分支')
assert.ok(requestConfirmIndex > confirmGateIndex, '直接提交确认分支应先打开确认弹窗')
assert.ok(confirmedActionPushIndex > requestConfirmIndex, '确认弹窗打开前不应追加“直接提交”用户消息')
assert.match(
@@ -178,22 +194,22 @@ test('AI mode waits for submit confirmation before adding submit action to the c
/requestInlineApplicationSubmitConfirmation\(targetMessage,\s*\{\s*\.\.\.options,\s*userText\s*\}\)/
)
const confirmStart = aiMode.indexOf('function confirmInlineApplicationSubmit()')
const confirmEnd = aiMode.indexOf('\nasync function runInlineApplicationSubmitPrecheck', confirmStart)
const confirmBlock = aiMode.slice(confirmStart, confirmEnd)
const confirmStart = applicationPreviewFlow.indexOf('function confirmInlineApplicationSubmit()')
const confirmEnd = applicationPreviewFlow.indexOf('\n async function runInlineApplicationSubmitPrecheck', confirmStart)
const confirmBlock = applicationPreviewFlow.slice(confirmStart, confirmEnd)
assert.match(confirmBlock, /userText:\s*context\.userText \|\| '直接提交'/)
assert.match(confirmBlock, /skipUserMessage:\s*false/)
const cancelStart = aiMode.indexOf('function cancelInlineApplicationSubmitConfirm()')
const cancelEnd = aiMode.indexOf('\nfunction confirmInlineApplicationSubmit', cancelStart)
const cancelBlock = aiMode.slice(cancelStart, cancelEnd)
const cancelStart = applicationPreviewFlow.indexOf('function cancelInlineApplicationSubmitConfirm()')
const cancelEnd = applicationPreviewFlow.indexOf('\n function confirmInlineApplicationSubmit', cancelStart)
const cancelBlock = applicationPreviewFlow.slice(cancelStart, cancelEnd)
assert.doesNotMatch(cancelBlock, /pushInlineUserMessage|pushInlineApplicationActionUserMessage/)
})
test('AI mode formats saved application draft as a detail table without continuing submit flow', () => {
assert.match(aiMode, /function buildInlineApplicationResultTable\(draftPayload = \{\}, options = \{\}\)/)
assert.match(aiMode, /function normalizeInlineApplicationStatusLabel\(value, fallback = ''\)/)
assert.match(aiMode, /submitted:\s*'审批中'/)
assert.equal(normalizeInlineApplicationStatusLabel('submitted'), '审批中')
assert.match(aiMode, /const statusLabel = normalizeInlineApplicationStatusLabel\(info\.statusLabel, options\.statusLabel\)/)
assert.match(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \|/)
assert.doesNotMatch(aiMode, /\| 单据类型 \| 单据编号 \| 单据状态 \| 当前节点 \| 日期 \| 地点 \| 事由 \| 金额 \| 操作 \|/)
@@ -204,9 +220,9 @@ test('AI mode formats saved application draft as a detail table without continui
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)
const resultBlock = aiMode.slice(resultStart, resultEnd)
const resultStart = applicationPreviewModel.indexOf('function buildInlineApplicationPreviewActionResultText')
const resultEnd = applicationPreviewModel.indexOf('\nexport function buildInlineApplicationDetailAction', resultStart)
const resultBlock = applicationPreviewModel.slice(resultStart, resultEnd)
const submitBranchIndex = resultBlock.indexOf('actionType === AI_APPLICATION_ACTION_SUBMIT')
const saveBranchIndex = resultBlock.indexOf("'### 申请草稿已保存'")
const saveBranch = resultBlock.slice(saveBranchIndex)
@@ -219,15 +235,16 @@ test('AI mode formats saved application draft as a detail table without continui
)
assert.doesNotMatch(saveBranch, /进入审批流程/)
const executeStart = aiMode.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = aiMode.indexOf('\nfunction handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = aiMode.slice(executeStart, executeEnd)
const executeStart = applicationPreviewFlow.indexOf('async function executeInlineApplicationPreviewAction')
const executeEnd = applicationPreviewFlow.indexOf('\n function handleInlineApplicationPreviewTextAction', executeStart)
const executeBlock = applicationPreviewFlow.slice(executeStart, executeEnd)
assert.match(executeBlock, /targetMessage\.suggestedActions = \[\]/)
assert.doesNotMatch(
executeBlock,
/targetMessage\.suggestedActions = isSubmit[\s\S]*buildInlineApplicationPreviewSuggestedActions\(targetMessage\.applicationPreview, draftPayload\)/
)
assert.match(executeBlock, /suggestedActions:\s*buildInlineApplicationDetailAction\(draftPayload\)/)
assert.match(executeBlock, /const detailActions = buildInlineApplicationDetailAction\(draftPayload\)/)
assert.match(executeBlock, /suggestedActions:\s*shouldAutoContinueNextTask[\s\S]*detailActions/)
})
test('AI mode locks application preview actions while estimate refresh is pending', () => {
@@ -236,13 +253,13 @@ test('AI mode locks application preview actions while estimate refresh is pendin
aiMode,
/function buildInlineApplicationPreviewSuggestedActions\(applicationPreview = \{\}, draftPayload = null\) \{[\s\S]*if \(isApplicationPreviewEstimatePendingPreview\(applicationPreview\)\) \{[\s\S]*return \[\]/
)
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value\)/)
assert.match(aiMode, /const isAiModeInputLocked = computed\(\(\) => applicationPreviewEstimatePending\.value \|\| isAiModeReceiptRecognitionPending\.value\)/)
assert.match(aiMode, /:disabled="isAiModeInputLocked"/)
assert.match(aiMode, /v-if="canShowInlineSuggestedActions\(message\)"/)
assert.match(aiMode, /:disabled="isInlineSuggestedActionDisabled\(action, message\)"/)
assert.match(
aiMode,
/message\.suggestedActions = \[\][\s\S]*const committed = await commitApplicationPreviewEditor\(message\)/
/message\.suggestedActions = \[\][\s\S]*const committed = await commitBaseApplicationPreviewEditor\(message\)/
)
assert.match(
aiMode,