2026-05-20 21:00:47 +08:00
|
|
|
|
import assert from 'node:assert/strict'
|
|
|
|
|
|
import { readFileSync } from 'node:fs'
|
|
|
|
|
|
import test from 'node:test'
|
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
buildAiAdviceViewModel,
|
|
|
|
|
|
buildAttachmentInsightViewModel,
|
|
|
|
|
|
buildAttachmentRiskCards
|
|
|
|
|
|
} from '../src/views/scripts/travelRequestDetailInsights.js'
|
|
|
|
|
|
|
|
|
|
|
|
const detailViewTemplate = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const detailViewScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/TravelRequestDetailView.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const requestsComposableScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/composables/useRequests.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const approvalCenterTemplate = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/ApprovalCenterView.vue', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const approvalCenterScript = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/views/scripts/ApprovalCenterView.js', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
const returnReasonDialog = readFileSync(
|
|
|
|
|
|
fileURLToPath(new URL('../src/components/shared/ReturnReasonDialog.vue', import.meta.url)),
|
|
|
|
|
|
'utf8'
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
const attachmentMeta = {
|
|
|
|
|
|
file_name: 'taxi-invoice.pdf',
|
|
|
|
|
|
media_type: 'application/pdf',
|
|
|
|
|
|
previewable: true,
|
|
|
|
|
|
document_info: {
|
|
|
|
|
|
document_type: 'taxi_receipt',
|
|
|
|
|
|
document_type_label: '出租车/网约车票据',
|
|
|
|
|
|
fields: [
|
|
|
|
|
|
{ label: '金额', value: '121.54' },
|
|
|
|
|
|
{ label: '日期', value: '2026-03-04' }
|
|
|
|
|
|
]
|
|
|
|
|
|
},
|
|
|
|
|
|
requirement_check: {
|
|
|
|
|
|
matches: false,
|
|
|
|
|
|
message: '附件类型与当前费用项目不匹配。'
|
|
|
|
|
|
},
|
|
|
|
|
|
analysis: {
|
|
|
|
|
|
severity: 'high',
|
|
|
|
|
|
label: '高风险',
|
|
|
|
|
|
headline: '票据类型不匹配',
|
|
|
|
|
|
summary: '交通票据挂在办公费明细下。',
|
|
|
|
|
|
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公费'],
|
|
|
|
|
|
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
test('attachment insight exposes recognition fields and rule basis', () => {
|
|
|
|
|
|
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
|
|
|
|
|
|
name: '办公费',
|
|
|
|
|
|
itemType: 'office'
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(insight.documentTypeLabel, '出租车/网约车票据')
|
|
|
|
|
|
assert.equal(insight.requirementLabel, '不符合当前费用类型')
|
|
|
|
|
|
assert.deepEqual(insight.fields, ['金额:121.54', '日期:2026-03-04'])
|
|
|
|
|
|
assert.ok(insight.ruleBasis.some((item) => item.includes('附件类型与当前费用项目不匹配')))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('AI advice card splits every attachment risk point with basis and suggestion', () => {
|
|
|
|
|
|
const riskCards = buildAttachmentRiskCards({
|
|
|
|
|
|
expenseItems: [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'item-1',
|
|
|
|
|
|
name: '办公费',
|
|
|
|
|
|
invoiceId: 'taxi-invoice.pdf'
|
|
|
|
|
|
}
|
|
|
|
|
|
],
|
|
|
|
|
|
attachmentMetaByItemId: {
|
|
|
|
|
|
'item-1': attachmentMeta
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
const advice = buildAiAdviceViewModel({
|
|
|
|
|
|
completionItems: [],
|
|
|
|
|
|
riskCards
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(riskCards.length, 2)
|
|
|
|
|
|
assert.equal(advice.badge, '优先整改')
|
|
|
|
|
|
assert.equal(advice.riskCards.length, 2)
|
|
|
|
|
|
assert.ok(advice.riskCards.every((card) => card.ruleBasis.length > 0))
|
|
|
|
|
|
assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('AI advice shows only the latest manual return while preserving return count context', () => {
|
|
|
|
|
|
const riskCards = buildAttachmentRiskCards({
|
|
|
|
|
|
claimRiskFlags: [
|
|
|
|
|
|
{
|
|
|
|
|
|
source: 'manual_return',
|
|
|
|
|
|
severity: 'medium',
|
|
|
|
|
|
label: '人工退回',
|
|
|
|
|
|
message: '第一次退回:缺少附件。',
|
|
|
|
|
|
reason: '缺少附件。',
|
|
|
|
|
|
return_count: 1,
|
|
|
|
|
|
return_stage: '直属领导审批',
|
|
|
|
|
|
risk_points: ['附件缺失或不清晰']
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
source: 'manual_return',
|
|
|
|
|
|
severity: 'medium',
|
|
|
|
|
|
label: '人工退回',
|
|
|
|
|
|
message: '第二次退回:超标说明不完整。',
|
|
|
|
|
|
reason: '超标说明不完整。',
|
|
|
|
|
|
return_count: 2,
|
|
|
|
|
|
return_stage: '财务审批',
|
|
|
|
|
|
risk_points: ['超出制度标准或缺少超标说明']
|
|
|
|
|
|
}
|
|
|
|
|
|
]
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
assert.equal(riskCards.length, 1)
|
|
|
|
|
|
assert.equal(riskCards[0].risk, '第二次退回:超标说明不完整。')
|
|
|
|
|
|
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('累计退回 2 次')))
|
|
|
|
|
|
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('财务审批')))
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('expense attachment actions keep preview as the only recognition entry point', () => {
|
|
|
|
|
|
assert.match(detailViewTemplate, /:aria-label="resolveAttachmentPreviewTitle\(item\)"/)
|
|
|
|
|
|
assert.match(detailViewScript, /return fileName \? `预览附件:\$\{fileName\}` : '预览附件'/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewTemplate, /aria-label="识别附件"/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewTemplate, /点击识别按钮/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewScript, /recognizeExpenseAttachment/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewScript, /recognizingExpenseId/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('expense detail table omits compact-breaking summary labels', () => {
|
|
|
|
|
|
assert.match(detailViewTemplate, /<div class="detail-expense-table">/)
|
|
|
|
|
|
assert.match(detailViewTemplate, /当前还没有费用明细/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewTemplate, /class="total-row"/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewTemplate, /expense-total-bar/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewTemplate, /合计 \{\{ expenseTotal \}\}/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewTemplate, /\{\{ uploadedExpenseCount \}\} 项已关联票据/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewTemplate, /\{\{ expenseSummaryText \}\}/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
test('expense detail table shows each item filled time from item creation time', () => {
|
|
|
|
|
|
assert.match(detailViewTemplate, /<th class="col-filled-at">填写时间<\/th>/)
|
|
|
|
|
|
assert.match(detailViewTemplate, /<td class="expense-filled-at col-filled-at">[\s\S]*\{\{ item\.filledAt \}\}/)
|
|
|
|
|
|
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(requestsComposableScript, /filledAt: formatDateTime\(item\?\.created_at\) \|\| '待同步'/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
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.equal(
|
|
|
|
|
|
(detailViewTemplate.match(/v-if="isEditableRequest && !item\.invoiceId"/g) || []).length,
|
|
|
|
|
|
2
|
|
|
|
|
|
)
|
|
|
|
|
|
assert.match(detailViewScript, /const attachments = invoiceId \? \[attachmentName \|\| invoiceId\] : \[\]/)
|
|
|
|
|
|
assert.match(detailViewScript, /attachmentStatus: attachments\.length \? '已关联票据' : '未上传'/)
|
|
|
|
|
|
assert.match(detailViewScript, /if \(item\?\.invoiceId\) \{[\s\S]*每条费用明细只能关联一张单据/)
|
|
|
|
|
|
assert.match(detailViewScript, /const fileCount = fileList\?\.length \|\| 0/)
|
|
|
|
|
|
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-21 09:28:33 +08:00
|
|
|
|
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, /itemPatch\.itemAmount = recognizedItemAmount/)
|
|
|
|
|
|
assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
|
|
|
|
|
|
assert.match(detailViewScript, /expenseEditor\.itemAmount = String\(recognizedItemAmount\)/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-20 21:00:47 +08:00
|
|
|
|
test('return reason dialog is wired into approval and detail return actions', () => {
|
|
|
|
|
|
assert.match(returnReasonDialog, /missing_attachment/)
|
|
|
|
|
|
assert.match(returnReasonDialog, /invoice_mismatch/)
|
|
|
|
|
|
assert.match(returnReasonDialog, /reason_codes/)
|
|
|
|
|
|
assert.match(approvalCenterTemplate, /<TravelRequestDetailView/)
|
|
|
|
|
|
assert.doesNotMatch(approvalCenterTemplate, /<ReturnReasonDialog/)
|
|
|
|
|
|
assert.match(detailViewTemplate, /<ReturnReasonDialog/)
|
|
|
|
|
|
assert.doesNotMatch(approvalCenterScript, /returnExpenseClaim/)
|
|
|
|
|
|
assert.match(detailViewScript, /returnExpenseClaim\(request\.value\.claimId, payload\)/)
|
|
|
|
|
|
assert.doesNotMatch(approvalCenterScript, /审批中心退回/)
|
|
|
|
|
|
assert.doesNotMatch(detailViewScript, /详情页退回/)
|
|
|
|
|
|
})
|