重构 expense_claims 服务模块结构并优化差旅票据审核逻辑, 增强用户代理服务的票据类型识别,前端报销创建页面拆分为 附件模型和会话模型模块,重构提交编排器和草稿关联确认流 程,更新知识库索引,补充单元测试。
191 lines
10 KiB
JavaScript
191 lines
10 KiB
JavaScript
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import test from 'node:test'
|
||
import { fileURLToPath } from 'node:url'
|
||
|
||
const createViewTemplate = readFileSync(
|
||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const createViewScript = readFileSync(
|
||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const reimbursementService = readFileSync(
|
||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const reviewActionsScript = readFileSync(
|
||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementReviewActions.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const submitComposerScript = readFileSync(
|
||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
const attachmentsScript = readFileSync(
|
||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
|
||
'utf8'
|
||
)
|
||
|
||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewDocumentDrawerAvailable"[\s\S]*title="单据识别"/)
|
||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewRiskDrawerAvailable"[\s\S]*title="显示风险"/)
|
||
assert.match(createViewTemplate, /title="调用流程"/)
|
||
|
||
assert.ok(
|
||
createViewTemplate.indexOf('title="报销识别核对"') < createViewTemplate.indexOf('title="单据识别"'),
|
||
'default review button should be placed before the document recognition button'
|
||
)
|
||
})
|
||
|
||
test('review drawer tool buttons switch modes instead of toggling the active mode closed', () => {
|
||
assert.match(createViewScript, /const isReviewOverviewDrawer = computed\(\(\) => reviewDrawerMode\.value === REVIEW_DRAWER_MODE_REVIEW\)/)
|
||
assert.match(createViewScript, /function switchReviewDrawerMode\(mode\) \{[\s\S]*if \(reviewDrawerMode\.value === mode\) \{[\s\S]*return[\s\S]*\}/)
|
||
assert.match(createViewScript, /function switchToReviewOverviewDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_REVIEW\)/)
|
||
assert.match(createViewScript, /function toggleReviewDocumentDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_DOCUMENTS\)/)
|
||
assert.match(createViewScript, /function toggleReviewRiskDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
|
||
assert.match(createViewScript, /function toggleReviewFlowDrawer\(\) \{[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_FLOW\)/)
|
||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_DOCUMENTS\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||
})
|
||
|
||
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
|
||
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
|
||
assert.ok(riskItemsBlock, 'risk item builder should be present')
|
||
|
||
assert.doesNotMatch(createViewTemplate, /review-side-risk-score/)
|
||
assert.doesNotMatch(createViewTemplate, /风险评分/)
|
||
assert.doesNotMatch(createViewTemplate, /暂无风险评分/)
|
||
assert.doesNotMatch(createViewScript, /function buildReviewRiskScore/)
|
||
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
|
||
assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
|
||
assert.match(createViewScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
|
||
assert.match(
|
||
createViewScript,
|
||
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
|
||
)
|
||
|
||
assert.match(
|
||
createViewTemplate,
|
||
/class="review-side-risk-item"[\s\S]*@click="appendReviewRiskBriefToConversation\(item\)"/
|
||
)
|
||
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
||
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
||
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
||
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
||
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
||
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
||
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
||
assert.match(createViewScript, /sourceLabel:\s*meta\.label/)
|
||
assert.doesNotMatch(createViewScript, /normalizedTitle\.includes\('AI预审'\)/)
|
||
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
|
||
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
|
||
assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/)
|
||
assert.doesNotMatch(createViewScript, /reviewRiskDetailDialog/)
|
||
assert.doesNotMatch(createViewScript, /function openReviewRiskDetail/)
|
||
|
||
assert.match(
|
||
createViewScript,
|
||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
||
)
|
||
})
|
||
|
||
test('review payload with risks opens risk drawer and travel overview uses travel-specific fields', () => {
|
||
assert.match(
|
||
createViewScript,
|
||
/reviewDrawerMode\.value = resolveReviewRiskBriefs\(payload\)\.length[\s\S]*\? REVIEW_DRAWER_MODE_RISK[\s\S]*: REVIEW_DRAWER_MODE_REVIEW/
|
||
)
|
||
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
|
||
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
||
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
||
assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
|
||
assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
|
||
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
|
||
assert.match(createViewTemplate, /wide: item\.wide/)
|
||
})
|
||
|
||
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
|
||
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
|
||
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
|
||
assert.doesNotMatch(createViewTemplate, /travel-calculator-modal/)
|
||
assert.doesNotMatch(createViewTemplate, /travelCalculatorResult\.total_amount/)
|
||
assert.match(createViewScript, /calculateTravelReimbursement/)
|
||
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
|
||
assert.match(createViewScript, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
|
||
assert.match(createViewScript, /根据您输入的地点和天数/)
|
||
assert.match(createViewScript, /匹配到您要出差的地区为/)
|
||
assert.match(createViewScript, /参考可报销合计/)
|
||
assert.match(createViewScript, /住宿费:\$\{hotelRate\} × \$\{days\} = \$\{hotelAmount\} 元/)
|
||
assert.match(createViewScript, /messages\.value\.push\(createMessage\('assistant', buildTravelCalculatorResultText\(payload\)/)
|
||
assert.match(reimbursementService, /export function calculateTravelReimbursement\(payload = \{\}\) \{[\s\S]*\/reimbursements\/travel-calculator/)
|
||
})
|
||
|
||
test('continuing receipt upload preserves prior review form context', () => {
|
||
assert.match(createViewScript, /function buildReviewFormContextFromPayload\(reviewPayload, inlineState = null\)/)
|
||
assert.match(createViewScript, /function buildBusinessTimeContextFromReviewValues\(values = \{\}\)/)
|
||
assert.match(
|
||
createViewScript,
|
||
/resolvedUploadDisposition === 'continue_existing'[\s\S]*buildReviewFormContextFromPayload\([\s\S]*activeReviewPayload\.value[\s\S]*reviewInlineForm\.value[\s\S]*extraContext\.review_form_values/s
|
||
)
|
||
assert.match(
|
||
createViewScript,
|
||
/inheritedReviewContext\.business_time_context[\s\S]*extraContext\.business_time_context = inheritedReviewContext\.business_time_context/s
|
||
)
|
||
})
|
||
|
||
test('review drawer save action is disabled while receipt recognition is submitting', () => {
|
||
assert.match(createViewScript, /const submitting = ref\(false\)/)
|
||
assert.match(
|
||
createViewScript,
|
||
/submitting\.value = true[\s\S]*recognizeOcrFiles\(files\)[\s\S]*submitting\.value = false/s
|
||
)
|
||
assert.match(
|
||
createViewTemplate,
|
||
/class="review-side-save-pill"[\s\S]*:disabled="reviewActionBusy \|\| submitting"[\s\S]*@click="saveInlineReviewChanges"/
|
||
)
|
||
assert.match(
|
||
createViewScript,
|
||
/async function handleReviewAction\(message, action\) \{[\s\S]*if \(!actionType \|\| submitting\.value \|\| reviewActionBusy\.value \|\| sessionSwitchBusy\.value\) return/
|
||
)
|
||
assert.match(
|
||
createViewScript,
|
||
/function saveInlineReviewChanges\(\) \{[\s\S]*\|\| submitting\.value[\s\S]*\|\| sessionSwitchBusy\.value[\s\S]*\) return/
|
||
)
|
||
})
|
||
|
||
test('draft creation starts composer attachment persistence after response rendering', () => {
|
||
assert.match(
|
||
submitComposerScript,
|
||
/void syncComposerFilesToDraft\(resolvedDraftClaimId, files\)\s*\.then\(\(\) => \{\s*persistSessionState\(\)\s*\}\)\s*\.catch\(\(error\) => \{/s
|
||
)
|
||
assert.doesNotMatch(
|
||
submitComposerScript,
|
||
/await syncComposerFilesToDraft\(resolvedDraftClaimId, files\)/
|
||
)
|
||
assert.ok(
|
||
submitComposerScript.indexOf('replaceMessage(pendingMessage.id, assistantMessage)') <
|
||
submitComposerScript.indexOf('void syncComposerFilesToDraft(resolvedDraftClaimId, files)'),
|
||
'assistant response should render before background attachment persistence starts'
|
||
)
|
||
assert.match(attachmentsScript, /function normalizeAttachmentMatchName\(value\)/)
|
||
assert.match(attachmentsScript, /const normalizedMatchBuckets = new Map\(\)/)
|
||
assert.match(
|
||
attachmentsScript,
|
||
/const targetItem = nextExactMatch \|\| nextNormalizedMatch \|\| fallbackMatch/
|
||
)
|
||
})
|
||
|
||
test('review summary renders markdown and save draft relies on backend response only', () => {
|
||
assert.match(
|
||
createViewTemplate,
|
||
/message\.text && message\.role === 'assistant' && message\.reviewPayload[\s\S]*v-html="renderMarkdown\(message\.text\)"/
|
||
)
|
||
assert.doesNotMatch(
|
||
reviewActionsScript,
|
||
/messages\.value\.push\(\s*createMessage\('assistant', actionConfig\.successMessage/
|
||
)
|
||
})
|