feat: 本体字段治理与风险规则模板执行器重构
- 新增本体字段注册表与字段治理审计脚本 - 重构风险规则模板执行器、DSL 验证与清单分类器 - 完善票据夹服务与差旅请求详情页交互 - 优化趋势图表与总览页数据展示 - 增强报销平台风险分级与模拟公司筛选 - 补充本体字段、风险规则生成与票据夹服务测试覆盖
This commit is contained in:
@@ -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\)/)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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'/)
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user