feat: 完善差旅票据行程提取与费用明细回填逻辑

增强文档智能识别的票据场景关键词和字段提取能力,优化
会话关联草稿报销单的解析路径,修复费用明细合并和票据
去重边界问题,前端改进报销创建和审批详情交互,补充单
元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 14:24:51 +08:00
parent b183b0bd5e
commit f28d7e6d16
24 changed files with 1565 additions and 433 deletions

View File

@@ -119,7 +119,7 @@ test('travel expense items describe departure return and lodging time below the
{
id: 'outbound-train',
item_type: 'train_ticket',
item_reason: '广州南北京南',
item_reason: '广州南-北京南',
item_location: '北京',
item_date: '2026-05-13',
item_amount: 354,
@@ -137,7 +137,7 @@ test('travel expense items describe departure return and lodging time below the
{
id: 'return-train',
item_type: 'train_ticket',
item_reason: '北京南广州南',
item_reason: '北京南-广州南',
item_location: '广州',
item_date: '2026-05-15',
item_amount: 354,

View File

@@ -110,3 +110,36 @@ test('composer exposes travel calculator and posts spreadsheet-backed result int
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/
)
})

View File

@@ -101,6 +101,29 @@ test('AI advice card splits every attachment risk point with basis and suggestio
assert.ok(advice.riskCards.every((card) => card.suggestion.includes('费用项目调整为交通费')))
})
test('AI advice splits claim attachment risk flags into specific points', () => {
const riskCards = buildAttachmentRiskCards({
claimRiskFlags: [
{
source: 'attachment_analysis',
severity: 'medium',
label: '中风险',
message: '费用明细第 2 条:日期字段:未识别到开票日期。',
summary: '当前附件可见部分内容,但金额、用途、日期或附件类型仍有缺失或不一致。',
points: [
'日期字段:未识别到开票日期或业务发生日期。',
'金额字段:附件识别金额 300.00 元与报销金额 88.00 元不一致。'
]
}
]
})
assert.equal(riskCards.length, 2)
assert.equal(riskCards[0].risk, '日期字段:未识别到开票日期或业务发生日期。')
assert.equal(riskCards[1].risk, '金额字段:附件识别金额 300.00 元与报销金额 88.00 元不一致。')
assert.ok(riskCards[0].ruleBasis.some((item) => item.includes('风险汇总')))
})
test('AI advice view model exposes grouped completion and risk sections', () => {
const advice = buildAiAdviceViewModel({
completionItems: ['补充业务地点', '补充报销金额'],
@@ -298,6 +321,54 @@ test('expense item upload patches OCR amount into the visible detail row', () =>
assert.match(detailViewScript, /expenseEditor\.itemAmount = String\(recognizedItemAmount\)/)
})
test('expense detail save is blocked while attachment recognition is running', () => {
assert.match(detailViewScript, /const uploadingExpenseId = ref\(''\)/)
assert.match(detailViewScript, /const actionBusy = computed\(\(\) =>[\s\S]*Boolean\(uploadingExpenseId\.value\)/)
assert.match(
detailViewTemplate,
/@click="saveExpenseEdit\(item\)"[\s\S]*:disabled="actionBusy"/
)
assert.match(
detailViewScript,
/if \(actionBusy\.value\) \{[\s\S]*toast\(uploadingExpenseId\.value \? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。'\)[\s\S]*return/
)
})
test('transport ticket descriptions use route format and invalid format becomes risk advice', () => {
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ride_ticket'\]\)/)
assert.match(detailViewScript, /const ROUTE_DESCRIPTION_PATTERN = \/\^\[A-Za-z0-9\\u4e00-\\u9fa5/)
assert.match(detailViewScript, /return isRouteDescriptionExpenseType\(itemType\) \? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'/)
assert.match(detailViewScript, /return isRouteDescriptionExpenseType\(itemType\) \? '始发地-目的地' : '业务报销说明'/)
assert.match(
detailViewScript,
/isRouteDescriptionExpenseType\(item\.itemType\) && !isValidRouteDescription\(item\.itemReason\)[\s\S]*issues\.push\('行程说明格式错误'\)/
)
assert.match(
detailViewScript,
/isRouteDescriptionExpenseType\(expenseEditor\.itemType\)[\s\S]*!isValidRouteDescription\(expenseEditor\.itemReason\)[\s\S]*return '行程说明格式应为“始发地-目的地”,例如:广州南-北京南。'/
)
assert.match(
detailViewScript,
/fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明,格式应为“始发地-目的地”。`/
)
})
test('transport ticket items no longer generate business location completion advice', () => {
const locationRequiredBlock = detailViewScript.match(/const LOCATION_REQUIRED_EXPENSE_TYPES = new Set\(\[[\s\S]*?\]\)/)?.[0] || ''
assert.match(locationRequiredBlock, /'travel'/)
assert.match(locationRequiredBlock, /'meeting'/)
assert.match(locationRequiredBlock, /'entertainment'/)
assert.doesNotMatch(locationRequiredBlock, /'train_ticket'/)
assert.doesNotMatch(locationRequiredBlock, /'flight_ticket'/)
assert.doesNotMatch(locationRequiredBlock, /'ride_ticket'/)
assert.match(
detailViewScript,
/const locationRequired = isLocationRequiredExpenseType\(item\.itemType\)[\s\S]*if \(locationRequired && isPlaceholderValue\(item\.itemLocation\)\) \{[\s\S]*issues\.push\('缺少地点'\)/
)
assert.doesNotMatch(detailViewScript, /完善第 1 条费用明细的业务地点/)
})
test('return reason dialog is wired into approval and detail return actions', () => {
assert.match(returnReasonDialog, /missing_attachment/)
assert.match(returnReasonDialog, /invoice_mismatch/)