feat: 本体字段治理与风险规则模板执行器重构

- 新增本体字段注册表与字段治理审计脚本
- 重构风险规则模板执行器、DSL 验证与清单分类器
- 完善票据夹服务与差旅请求详情页交互
- 优化趋势图表与总览页数据展示
- 增强报销平台风险分级与模拟公司筛选
- 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 15:46:56 +08:00
parent e12b140508
commit 34457f9c3e
81 changed files with 4858 additions and 1073 deletions

View File

@@ -75,6 +75,21 @@ test('documents center reloads immediately when entered or clicked again', () =>
assert.match(appShellComposable, /reloadDocumentCenterRequests,/)
})
test('document detail navigation preserves document center list query', () => {
assert.match(
appShellComposable,
/function openRequestDetail\(request\) \{[\s\S]*name: 'app-document-detail'[\s\S]*params: \{ requestId: request\.claimId \|\| request\.id \},[\s\S]*query: \{ \.\.\.route\.query \}/
)
assert.match(
appShellComposable,
/function closeRequestDetail\(\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
)
assert.match(
appShellComposable,
/async function handleRequestDeleted\(payload = \{\}\) \{[\s\S]*router\.push\(\{ name: 'app-documents', query: \{ \.\.\.route\.query \} \}\)/
)
})
test('application entry keeps its own assistant source without creating a separate dialog', () => {
assert.match(appShellComposable, /const SMART_ENTRY_SOURCE_APPLICATION = 'application'/)
assert.match(appShellComposable, /function openExpenseApplicationCreate\(\) \{[\s\S]*openFinancialAssistantCreate\(SMART_ENTRY_SOURCE_APPLICATION\)/)

View File

@@ -35,13 +35,35 @@ test('documents center top tabs start from all and show document category labels
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_REVIEW = '审核单'/)
assert.match(documentsCenterView, /const DOCUMENT_SCOPE_ARCHIVE = '归档'/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)\)/)
assert.match(documentsCenterView, /const initialScopeTab = resolveInitialScopeTab\(\)/)
assert.match(documentsCenterView, /const activeScopeTab = ref\(initialScopeTab\)/)
assert.match(
documentsCenterView,
/function resolveInitialScopeTab\(\) \{[\s\S]*readDocumentCenterQueryText\('dc_scope'\)[\s\S]*return readDocumentScope\(DOCUMENT_SCOPE_ALL, scopeTabs\)/
)
assert.match(
documentsCenterView,
/const scopeTabs = \[[\s\S]*DOCUMENT_SCOPE_ALL[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT[\s\S]*DOCUMENT_SCOPE_REVIEW[\s\S]*DOCUMENT_SCOPE_ARCHIVE[\s\S]*\]/
)
})
test('documents center persists pagination and filters in route query for detail return', () => {
assert.match(documentsCenterView, /import \{ useRoute, useRouter \} from 'vue-router'/)
assert.match(documentsCenterView, /const DOCUMENT_CENTER_QUERY_KEYS = new Set\(/)
assert.match(documentsCenterView, /'dc_page'/)
assert.match(documentsCenterView, /'dc_page_size'/)
assert.match(documentsCenterView, /const currentPage = ref\(readDocumentCenterQueryNumber\('dc_page', 1\)\)/)
assert.match(documentsCenterView, /const pageSize = ref\(resolveInitialPageSize\(\)\)/)
assert.match(
documentsCenterView,
/function buildDocumentCenterRouteQuery\(\) \{[\s\S]*nextQuery\.dc_page = String\(currentPage\.value\)[\s\S]*nextQuery\.dc_page_size = String\(pageSize\.value\)/
)
assert.match(
documentsCenterView,
/watch\(\s*\[currentPage, pageSize, activeScopeTab, activeStatusTab, activeDocumentType, activeScene, listKeyword, appliedStart, appliedEnd\],[\s\S]*router\.replace\(\{ name: 'app-documents', query: nextQuery \}\)/
)
})
test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)

View File

@@ -21,6 +21,10 @@ const barChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/BarChart.vue', import.meta.url)),
'utf8'
)
const trendChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/TrendChart.vue', import.meta.url)),
'utf8'
)
test('finance dashboard keeps legacy ranking range constants for backend compatibility', () => {
assert.deepEqual(departmentRangeOptions, [
@@ -57,3 +61,33 @@ test('finance ranking bar chart can display ranking metadata', () => {
assert.match(overviewViewModel, /meta: `\$\{Number\(item\.employeeCount/)
assert.match(overviewViewModel, /meta: `\$\{item\.department/)
})
test('daily amount trend uses stacked category bars with clear unit and legend', () => {
assert.match(overviewView, /:category-amount-series="activeTrend\.categoryAmountSeries"/)
assert.match(overviewView, /:key="`finance-amount-\$\{financeDashboardRenderKey\}`"/)
assert.match(overviewView, /:key="`finance-count-\$\{financeDashboardRenderKey\}`"/)
assert.match(overviewView, /return financeDashboardLoading\.value\s*\n\}/)
assert.doesNotMatch(overviewView, /financeDashboardLoading\.value && !financeDashboardLoaded\.value/)
assert.match(overviewViewModel, /categoryAmountSeries: \[\]/)
assert.match(overviewViewModel, /financeDashboardRenderKey/)
assert.match(overviewViewModel, /financeDashboardRequestSeq/)
assert.match(overviewViewModel, /requestSeq !== financeDashboardRequestSeq/)
assert.match(overviewViewModel, /financeDashboardRenderKey\.value \+= 1/)
assert.match(trendChart, /categoryAmountSeries/)
assert.match(trendChart, /CustomChart as EChartsCustomChart/)
assert.match(trendChart, /type: 'custom'/)
assert.match(trendChart, /renderStackedAmountBar/)
assert.match(trendChart, /resolveCategoryColor/)
assert.match(trendChart, /expenseCategoryColorMap/)
assert.match(trendChart, /clipPath/)
assert.match(trendChart, /enterFrom/)
assert.match(trendChart, /originY: zeroY/)
assert.match(trendChart, /scaleY: 0/)
assert.match(trendChart, /chart-unit/)
assert.match(trendChart, /unitLabel/)
assert.match(trendChart, /legendItems/)
assert.match(trendChart, /单位:元/)
assert.match(trendChart, /单位:单/)
assert.doesNotMatch(trendChart, /name:\s*isCountMode\.value/)
assert.doesNotMatch(trendChart, /stack: 'expenseAmount'/)
})

View File

@@ -13,30 +13,38 @@ function testReceiptFolderViewSurface() {
assert.match(view, /activeStatus = ref\('all'\)/)
assert.match(view, /value: 'all'/)
assert.match(view, /value: 'unlinked'/)
assert.match(view, /value: 'linked'/)
assert.match(view, /openAssociateDialog/)
assert.match(view, /receipt-detail-toolbar/)
assert.match(view, /receipt-dashboard/)
assert.match(view, /receipt-dashboard-preview/)
assert.match(view, /receipt-dashboard-side/)
assert.match(view, /receipt-dashboard-bottom/)
assert.match(view, /receipt-ocr-panel/)
assert.match(view, /receipt-status-panel/)
assert.match(view, /keyReceiptFields/)
assert.match(view, /editableOtherFields/)
assert.match(view, /ocrPreviewFields/)
assert.match(view, /class="receipt-key-grid"/)
assert.match(view, /class="receipt-other-collapse"/)
assert.match(view, /class="receipt-other-scroll"/)
assert.match(view, /<EnterpriseDetailPage/)
assert.match(view, /variant="receipt-folder-detail"/)
assert.match(view, /<template #main>/)
assert.match(view, /<template #side>/)
assert.match(view, /<template #bottom>/)
assert.match(view, /receipt-preview-panel/)
assert.match(view, /receipt-ticket-info-panel/)
assert.match(view, /receipt-association-panel/)
assert.match(view, /receipt-edit-log-section/)
assert.match(view, /receipt-all-field-grid/)
assert.match(view, /receiptInfoEditing/)
assert.match(view, /startReceiptInfoEdit/)
assert.match(view, /cancelReceiptInfoEdit/)
assert.match(view, /receiptEditLogs/)
assert.match(view, /previewFrameUrl/)
assert.match(view, /previewTransform/)
assert.match(view, /String\(value \?\? ''\)\.trim\(\)/)
assert.match(view, /openAssociateDialogForCurrentReceipt/)
assert.match(view, /createReceiptDetailDashboardModel/)
assert.match(view, /ElCollapse/)
assert.doesNotMatch(view, /addField/)
assert.match(view, /const isTrainTicket = computed/)
assert.doesNotMatch(view, /打开源文件/)
assert.match(view, /createReceiptDetailFieldModel/)
assert.doesNotMatch(view, /receipt-detail-toolbar/)
assert.doesNotMatch(view, /receipt-side-stack/)
assert.doesNotMatch(view, /receipt-bottom-grid/)
assert.doesNotMatch(view, /receipt-status-panel/)
assert.doesNotMatch(view, /receipt-key-grid/)
assert.doesNotMatch(view, /receipt-other-collapse/)
assert.doesNotMatch(view, /ElCollapse/)
assert.doesNotMatch(view, /openSourceFile/)
assert.match(view, /back-label=/)
assert.doesNotMatch(view, /back-btn/)
assert.match(view, /deleteCurrentReceipt/)
assert.match(view, /ElCheckboxGroup/)
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
@@ -93,60 +101,66 @@ function testReceiptFolderDetailLayoutAdjustments() {
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css')
const fieldModel = readProjectFile('web/src/views/scripts/receiptFolderDetailFields.js')
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
const detailPage = readProjectFile('web/src/components/shared/EnterpriseDetailPage.vue')
assert.match(receiptView, /showStatusColumn/)
assert.match(receiptView, /<col v-if="showStatusColumn" class="col-status">/)
assert.match(receiptView, /<th v-if="showStatusColumn">/)
assert.match(receiptView, /document_date/)
assert.match(receiptView, /<td>\s*<strong class="doc-id">/)
assert.match(receiptView, /<td v-if="showStatusColumn">\s*<span class="status-tag"/)
assert.match(receiptView, /const activeStatus = ref\('all'\)/)
assert.match(receiptView, /import EnterpriseDetailCard/)
assert.match(receiptView, /import EnterpriseDetailPage/)
assert.match(receiptView, /<EnterpriseDetailPage/)
assert.match(receiptView, /variant="receipt-folder-detail"/)
assert.match(receiptView, /<EnterpriseDetailCard class="receipt-basic-panel"/)
assert.match(receiptView, /receipt-dashboard-preview/)
assert.match(receiptView, /receipt-dashboard-bottom/)
assert.match(receiptView, /createReceiptDetailFieldModel/)
assert.match(receiptView, /createReceiptDetailDashboardModel/)
assert.match(receiptView, /<td[^>]*>\s*<strong class="doc-id">/)
assert.match(receiptView, /buildDetailPayload\(\)/)
assert.match(receiptView, /receiptDetailSubtitle/)
assert.match(receiptView, /receiptDetailTopBarPayload/)
assert.match(receiptView, /eyebrow:/)
assert.match(receiptView, /detail-topbar-change/)
assert.doesNotMatch(receiptView, /<article v-else class="receipt-folder-detail/)
assert.doesNotMatch(receiptView, /class="back-btn"/)
assert.doesNotMatch(receiptView, /receipt-detail-head/)
assert.doesNotMatch(receiptView, /detail-actions receipt-detail-foot/)
assert.doesNotMatch(receiptView, /receipt-basic-panel/)
assert.doesNotMatch(receiptView, /receipt-ocr-panel/)
assert.match(receiptStyles, /\.receipt-folder-list th:first-child/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-scroll\)[\s\S]*display: flex/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-grid\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-bottom\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.detail-actions\)/)
assert.match(receiptStyles, /\.receipt-folder-detail :deep\(\.enterprise-detail-card \.card-head\)/)
assert.match(receiptStyles, /\.receipt-detail-toolbar/)
assert.match(receiptStyles, /\.receipt-dashboard/)
assert.match(receiptStyles, /\.receipt-dashboard-bottom/)
assert.match(receiptStyles, /\.receipt-preview-tools/)
assert.match(receiptStyles, /\.receipt-log-list/)
assert.match(receiptStyles, /\.receipt-key-grid/)
assert.match(receiptStyles, /\.receipt-other-collapse/)
assert.match(receiptStyles, /\.receipt-other-scroll/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-head\b/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-layout\b/)
assert.doesNotMatch(receiptStyles, /\.detail-loading\b/)
assert.doesNotMatch(receiptStyles, /\.back-btn\b/)
assert.doesNotMatch(receiptStyles, /\.danger-btn\b/)
assert.match(receiptStyles, /\.receipt-preview-panel/)
assert.match(receiptStyles, /\.receipt-ticket-info-panel/)
assert.match(receiptStyles, /\.receipt-association-panel/)
assert.match(receiptStyles, /\.receipt-preview-box[\s\S]*height: clamp\(380px, 56vh, 640px\)/)
assert.match(receiptStyles, /\.receipt-all-field-grid/)
assert.match(receiptStyles, /\.receipt-edit-log-list/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/)
assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/)
assert.doesNotMatch(receiptStyles, /\.receipt-bottom-grid/)
assert.doesNotMatch(receiptStyles, /\.receipt-log-list/)
assert.match(detailPage, /\$slots\.bottom/)
assert.match(detailPage, /class="detail-bottom"/)
assert.match(fieldModel, /TRAIN_KEY_FIELD_DEFINITIONS/)
assert.match(fieldModel, /id: 'invoice_number'/)
assert.match(fieldModel, /id: 'invoice_date'/)
assert.match(fieldModel, /id: 'fare'/)
assert.match(fieldModel, /id: 'passenger_name'/)
assert.match(fieldModel, /syncEditableFieldsToTopLevel/)
const dashboardModel = readProjectFile('web/src/views/scripts/receiptFolderDetailDashboard.js')
assert.match(dashboardModel, /createReceiptDetailDashboardModel/)
assert.match(dashboardModel, /basicInfoItems/)
assert.match(dashboardModel, /operationLogs/)
assert.match(dashboardModel, /archiveInfoItems/)
assert.match(dashboardModel, /linkedClaimItems/)
assert.doesNotMatch(dashboardModel, /operationLogs/)
assert.doesNotMatch(dashboardModel, /archiveInfoItems/)
}
function testAssistantUnlinkedReceiptPrompt() {
const submitComposer = readProjectFile('web/src/views/scripts/useTravelReimbursementSubmitComposer.js')
const assistantView = readProjectFile('web/src/views/scripts/TravelReimbursementCreateView.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/)
}
function run() {
@@ -155,6 +169,7 @@ function run() {
testAppShellWiresReceiptFolder()
testSharedDocumentListStyleReuse()
testReceiptFolderDetailLayoutAdjustments()
testAssistantUnlinkedReceiptPrompt()
console.log('receipt folder view tests passed')
}

View File

@@ -74,6 +74,58 @@ test('claim mapper falls back to employee name for legacy profile lookup', () =>
assert.equal(request.profileEmployeeId, 'Legacy Alice')
})
test('claim mapper keeps low reimbursement risk as low risk instead of medium', () => {
const riskMessage = '票据商品或服务描述较笼统,建议审批人核对真实用途和明细清单。'
const request = mapExpenseClaimToRequest({
id: 'claim-low-risk-1',
claim_no: 'RE-LOW-RISK-1',
employee_name: 'Alice',
department_name: 'Finance',
expense_type: 'travel',
reason: 'Trip',
location: 'Shanghai',
amount: 354,
invoice_count: 1,
occurred_at: '2026-02-20T00:00:00.000Z',
created_at: '2026-06-03T04:22:16.000Z',
updated_at: '2026-06-03T04:25:48.000Z',
status: 'draft',
approval_stage: WAIT_SUBMIT,
risk_flags_json: [
{
source: 'submission_review',
hit_source: 'rule_center',
severity: 'low',
action: 'warning',
label: '差旅票据服务内容笼统低风险',
message: riskMessage,
risk_domain: 'invoice',
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter',
business_stage: 'reimbursement'
}
],
items: [
{
id: 'item-low-risk-train',
item_date: '2026-02-20',
item_type: 'train_ticket',
item_reason: '武汉-上海',
item_location: '',
item_amount: 354,
invoice_id: 'claim-low-risk-1/item-low-risk-train/train.pdf'
}
]
})
assert.equal(request.riskTone, 'low')
assert.equal(request.riskLabel, '低风险')
assert.equal(request.riskSummary, riskMessage)
assert.equal(request.expenseItems[0].riskTone, 'low')
assert.equal(request.expenseItems[0].riskLabel, '低风险')
assert.equal(request.expenseItems[0].riskText, riskMessage)
})
test('application claims are mapped as application documents', () => {
const request = mapExpenseClaimToRequest({
id: 'claim-application-1',

View File

@@ -311,13 +311,17 @@ test('guided reimbursement requires application selection for travel and enterta
assert.equal(submitOptions.extraContext.review_action, 'save_draft')
assert.equal(submitOptions.extraContext.review_form_values.application_claim_no, 'AP-202605-001')
assert.equal(submitOptions.extraContext.review_form_values.reason, '去上海支持项目部署')
assert.equal(submitOptions.extraContext.review_form_values.business_location, '上海')
assert.equal(submitOptions.extraContext.review_form_values.location, '上海')
assert.equal(submitOptions.extraContext.review_form_values.amount, '')
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800')
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20 至 2026-05-23')
assert.equal(submitOptions.extraContext.review_form_values.application_days, '4 天')
assert.equal(submitOptions.extraContext.review_form_values.transport_mode, '火车')
assert.equal(submitOptions.extraContext.review_form_values.application_transport_mode, '火车')
assert.equal(submitOptions.extraContext.review_form_values.reimbursement_type, undefined)
assert.equal(submitOptions.extraContext.review_form_values.reason_value, undefined)
assert.equal(submitOptions.extraContext.review_form_values.business_time, undefined)
assert.equal(submitOptions.extraContext.review_form_values.business_location, undefined)
assert.equal(submitOptions.extraContext.review_form_values.application_lodging_daily_cap, '600元/天')
assert.equal(submitOptions.extraContext.review_form_values.application_subsidy_daily_cap, '120元/天')
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { ref } from 'vue'
import {
buildReviewFormContextFromPayload,
buildLocallySyncedReviewPayload,
buildReviewNextStepRichCopy,
buildReviewPlainFollowupCopy,
@@ -410,6 +411,40 @@ test('continuing receipt upload preserves prior review form context', () => {
)
})
test('review form context emits ontology fields instead of local aliases', () => {
const context = buildReviewFormContextFromPayload(
{
edit_fields: [
{ key: 'expense_type', value: '' },
{ key: 'occurred_date', value: '' },
{ key: 'transport_type', value: '' },
{ key: 'reason', value: '' },
{ key: 'amount', value: '' },
{ key: 'business_location', value: '' },
{ key: 'attachment_names', value: '' }
]
},
{
expense_type: '差旅费',
occurred_date: '2026-06-01 至 2026-06-03',
transport_type: '火车',
reason_value: '支撑国网仿生产环境部署',
location: '上海',
amount: '3000',
attachment_names: 'ticket.pdf'
}
)
assert.equal(context.review_form_values.expense_type, '差旅费')
assert.equal(context.review_form_values.time_range, '2026-06-01 至 2026-06-03')
assert.equal(context.review_form_values.transport_mode, '火车')
assert.equal(context.review_form_values.reason, '支撑国网仿生产环境部署')
assert.equal(context.review_form_values.attachments, 'ticket.pdf')
assert.equal(context.review_form_values.occurred_date, undefined)
assert.equal(context.review_form_values.transport_type, undefined)
assert.equal(context.review_form_values.reason_value, undefined)
})
test('review drawer save action is disabled while receipt recognition is submitting', () => {
assert.match(createViewScript, /const submitting = ref\(false\)/)
assert.match(

View File

@@ -516,7 +516,7 @@ test('AI advice template renders grouped section titles with completion before r
})
test('AI advice risk section uses compact card styling hooks', () => {
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone, \{ 'is-highlighted': isHighlightedRiskCard\(card\) \}\]"/)
assert.match(detailViewTemplate, /class="risk-advice-compact-meta"/)
assert.doesNotMatch(detailViewTemplate, /section\.hiddenCount/)
assert.doesNotMatch(detailViewTemplate, /risk-advice-more/)
@@ -538,13 +538,25 @@ test('AI advice risk section uses compact card styling hooks', () => {
})
test('expense rows show a major-risk warning icon before time', () => {
assert.match(detailViewTemplate, /'has-major-risk': isMajorExpenseRisk\(item\)/)
assert.match(detailViewTemplate, /class="mdi mdi-alert expense-risk-indicator"/)
assert.match(detailViewTemplate, /'has-major-risk': hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewTemplate, /class="expense-risk-indicator"/)
assert.match(detailViewTemplate, /@click="focusExpenseRisk\(item\)"/)
assert.match(detailViewStyle, /\.expense-risk-indicator \{/)
assert.match(detailViewScript, /function isMajorExpenseRisk\(item\)/)
assert.match(detailViewScript, /function hasExpenseRiskIndicator\(item\)/)
assert.match(detailViewScript, /buildItemClaimRiskState\(item, resolveClaimRiskFlags\(\)\)/)
})
test('expense risk indicator can focus and flash related risk card', () => {
assert.match(detailViewTemplate, /:id="resolveRiskCardDomId\(card\)"/)
assert.match(detailViewTemplate, /:data-risk-card-id="card\.id"/)
assert.match(detailViewTemplate, /'is-highlighted': isHighlightedRiskCard\(card\)/)
assert.match(detailViewScript, /async function focusExpenseRisk\(item\)/)
assert.match(detailViewScript, /document\.getElementById\(resolveRiskCardDomId\(card\)\)/)
assert.match(detailViewScript, /scrollIntoView\(\{ behavior: 'smooth', block: 'center' \}\)/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.is-highlighted/)
assert.match(detailViewStyle, /@keyframes risk-card-flash/)
})
test('AI advice shows only the latest manual return while preserving return count context', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
@@ -640,7 +652,7 @@ test('ticket item types and system allowance row are visible but read only', ()
assert.match(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', 'travel_allowance'\]\)/)
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-attachment-note"/)
assert.match(detailViewTemplate, /v-else-if="item\.isSystemGenerated" class="system-attachment-note"/)
assert.match(detailViewScript, /系统自动计算的补贴行不能手动编辑/)
assert.match(detailViewScript, /系统自动计算的补贴行不能删除/)
})
@@ -664,13 +676,29 @@ test('expense detail table shows each item filled time from item creation time',
assert.match(detailViewTemplate, /<span>条款填写时间<\/span>/)
assert.match(detailViewScript, /function formatExpenseFilledTime\(value\)/)
assert.match(detailViewScript, /source\?\.filledAt[\s\S]*source\?\.created_at/)
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 6 \+ \(isEditableRequest\.value \? 1 : 0\)/)
assert.match(detailViewScript, /expenseTableColumnCount = computed\(\s*\(\) => 7 \+ \(isEditableRequest\.value \? 1 : 0\)/)
assert.match(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
})
test('expense detail table has per-item risk explanation column', () => {
assert.match(detailViewTemplate, /<th class="col-risk-note">异常说明<\/th>/)
assert.match(detailViewTemplate, /v-model="expenseEditor\.itemNote"/)
assert.match(detailViewTemplate, /hasExpenseRiskOrAbnormal\(item\)[\s\S]*待补充异常说明/)
assert.match(detailViewScript, /itemNote: ''/)
assert.match(detailViewScript, /expenseEditor\.itemNote = item\.itemNote \|\| ''/)
assert.match(detailViewScript, /item_note: expenseEditor\.itemNote\.trim\(\)/)
assert.match(detailViewScript, /itemNote: expenseEditor\.itemNote\.trim\(\)/)
assert.match(detailViewScript, /function hasExpenseRiskOrAbnormal\(item\)/)
assert.match(detailExpenseModelScript, /const itemNote = String\(source\?\.itemNote \?\? source\?\.item_note \?\? ''\)\.trim\(\)/)
assert.match(requestsComposableScript, /const itemNote = String\(item\?\.item_note \|\| item\?\.itemNote \|\| ''\)\.trim\(\)/)
})
test('expense item upload remains limited to one receipt per detail row', () => {
assert.match(detailViewTemplate, /ref="expenseUploadInput"[\s\S]*type="file"/)
assert.doesNotMatch(detailViewTemplate, /\bmultiple\b/)
assert.doesNotMatch(
detailViewTemplate,
/ref="expenseUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleExpenseFileChange"/
)
assert.equal(
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId && !item\.isSystemGenerated"/g) || []).length,
2
@@ -682,6 +710,34 @@ test('expense item upload remains limited to one receipt per detail row', () =>
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
})
test('detail smart entry confirms receipt upload before running recognition', () => {
assert.match(detailViewTemplate, /@click="triggerSmartEntryUpload"/)
assert.match(detailViewTemplate, /ref="smartEntryUploadInput"[\s\S]*\bmultiple\b[\s\S]*@change="handleSmartEntryFileChange"/)
assert.match(detailViewTemplate, /:open="smartEntryUploadDialogOpen"/)
assert.match(detailViewTemplate, /v-if="smartEntryRecognitionBusy" class="expense-recognition-banner"/)
assert.match(detailViewTemplate, /uploadingExpenseId === item\.id" class="system-attachment-note pending"/)
assert.match(detailViewTemplate, /title="上传报销附件"/)
assert.match(detailViewTemplate, /@click="chooseSmartEntryFile"/)
assert.match(detailViewTemplate, /@click="clearSmartEntryFile"/)
assert.match(detailViewTemplate, /@confirm="confirmSmartEntryUpload"/)
assert.match(detailViewScript, /const smartEntryUploadDialogOpen = ref\(false\)/)
assert.match(detailViewScript, /const smartEntryRecognitionBusy = ref\(false\)/)
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*smartEntryRecognitionBusy\.value/)
assert.match(detailViewScript, /const smartEntrySelectedFiles = ref\(\[\]\)/)
assert.match(detailViewScript, /function triggerSmartEntryUpload\(\)[\s\S]*smartEntryUploadDialogOpen\.value = true/)
assert.match(detailViewScript, /function handleSmartEntryFileChange\(event\)/)
assert.match(detailViewScript, /smartEntrySelectedFiles\.value = files/)
assert.match(detailViewScript, /function startSmartEntryRecognitionTask\(\{ claimId, files, itemSnapshots \}\)/)
assert.match(detailViewScript, /function subscribeSmartEntryRecognitionTask\(claimId, listener\)/)
assert.match(detailViewScript, /const smartEntryRecognitionCurrent = ref\(0\)/)
assert.match(detailViewScript, /return `附件识别中(\$\{current\}\/\$\{total\}),请稍候。识别完成前暂不可编辑费用明细。`/)
assert.match(detailViewScript, /const \{ task, reused \} = startSmartEntryRecognitionTask\(\{[\s\S]*claimId: request\.value\.claimId[\s\S]*itemSnapshots: expenseItems\.value/)
assert.match(detailViewScript, /bindSmartEntryRecognitionTask\(request\.value\.claimId\)/)
assert.match(detailViewScript, /void runSmartEntryRecognitionTask\(task, pendingFiles\)/)
assert.match(detailViewScript, /const payload = await uploadExpenseClaimItemAttachment\(task\.claimId, targetItem\.id, file\)/)
assert.doesNotMatch(detailViewScript, /function openAiEntry\(\)[\s\S]*emit\('openAssistant'/)
})
test('expense item upload patches OCR amount into the visible detail row', () => {
assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
assert.match(detailViewScript, /const recognizedItemDate = normalizeIsoDateValue\(payload\?\.item_date \?\? payload\?\.itemDate\)/)
@@ -701,6 +757,7 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
assert.match(detailViewScript, /const amountText = String\(expenseEditor\.itemAmount \|\| ''\)\.trim\(\)/)
assert.match(detailViewScript, /const nextAmount = amountText \? Number\(amountText\) : 0/)
assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
assert.match(detailViewScript, /itemPayload = \{[\s\S]*item_note: expenseEditor\.itemNote\.trim\(\)/)
})
test('travel detail AI advice uses material prompts only for required hotel receipts', () => {

View File

@@ -63,17 +63,22 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
})
test('detail submit requires override reasons for high-risk claims', () => {
test('detail submit no longer requires a separate high-risk override dialog', () => {
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
assert.match(detailViewTemplate, /重大风险/)
assert.match(detailViewTemplate, /goToPreviousSubmitRisk/)
assert.match(detailViewTemplate, /goToNextSubmitRisk/)
assert.match(detailViewTemplate, /v-model="riskOverrideReasons\[currentSubmitRiskWarning\.id\]"/)
assert.match(detailViewScript, /const submitRiskWarnings = computed/)
assert.match(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
const handleSubmit = extractFunction(detailViewScript, 'handleSubmit')
const confirmSubmitRequest = extractFunction(detailViewScript, 'confirmSubmitRequest')
assert.doesNotMatch(handleSubmit, /openRiskOverrideDialog/)
assert.doesNotMatch(confirmSubmitRequest, /openRiskOverrideDialog/)
assert.doesNotMatch(detailViewScript, /submitRiskWarnings\.value\.length && !hasRiskOverrideExplanation\.value/)
assert.match(detailViewScript, /function confirmRiskOverrideReasons\(\)/)
assert.match(detailViewScript, /updateExpenseClaim\(request\.value\.claimId,\s*\{\s*reason: nextNote/s)
assert.match(detailViewScript, /超标说明:\$\{tags\}/)
assert.match(detailViewTemplate, /异常说明/)
})
test('detail header and fallback progress use reimbursement wording', () => {