feat: 增强差旅报销审核流程与票据智能推理

优化本体解析和编排器的差旅场景处理能力,完善报销单草稿
保存和费用明细同步逻辑,前端报销创建页面增加行程推理和
票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 16:09:47 +08:00
parent f28d7e6d16
commit e701fa01da
33 changed files with 3033 additions and 337 deletions

View File

@@ -153,6 +153,10 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
})
test('AI advice view model omits empty sections', () => {
const readyAdvice = buildAiAdviceViewModel({
completionItems: [],
riskCards: []
})
const completionOnlyAdvice = buildAiAdviceViewModel({
completionItems: ['补充业务地点'],
riskCards: []
@@ -172,6 +176,8 @@ test('AI advice view model omits empty sections', () => {
]
})
assert.deepEqual(readyAdvice.sections, [])
assert.equal(readyAdvice.badge, '可直接提交')
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险'])
})
@@ -192,6 +198,8 @@ test('AI advice risk section uses compact card styling hooks', () => {
assert.match(detailViewTemplate, /class="\['risk-advice-card', card\.tone\]"/)
assert.match(detailViewStyle, /\.validation-card \{\s*border: 1px solid #e5e7eb;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card \{\s*display: grid;\s*gap: 8px;\s*padding: 12px 12px 11px;/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-card\.low/)
assert.match(detailViewStyle, /\.risk-advice-card\.low/)
assert.match(detailViewStyle, /\.validation-section--risk \.risk-advice-meta ul,\s*\.validation-section--risk \.risk-advice-meta p \{\s*margin: 0;/)
})
@@ -316,9 +324,35 @@ test('expense item upload remains limited to one receipt per detail row', () =>
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\)/)
assert.match(detailViewScript, /const recognizedItemType = String\(payload\?\.item_type \?\? payload\?\.itemType \?\? ''\)\.trim\(\)/)
assert.match(detailViewScript, /const recognizedItemReason = String\(payload\?\.item_reason \?\? payload\?\.itemReason \?\? ''\)\.trim\(\)/)
assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/)
assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
assert.match(detailViewScript, /expenseEditor\.itemAmount = String\(recognizedItemAmount\)/)
assert.match(detailViewScript, /populateExpenseEditor\(\{ \.\.\.item, \.\.\.itemPatch \}\)/)
})
test('expense detail edit keeps delete but removes cancel and allows draft placeholders', () => {
assert.doesNotMatch(detailViewTemplate, /@click="cancelExpenseEdit"/)
assert.doesNotMatch(detailViewScript, /function cancelExpenseEdit/)
assert.match(detailViewScript, /if \(expenseEditor\.itemDate && !isValidIsoDate\(expenseEditor\.itemDate\)\)/)
assert.doesNotMatch(detailViewScript, /请输入费用说明。/)
assert.doesNotMatch(detailViewScript, /请输入大于 0 的费用金额。/)
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/)
})
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
assert.match(detailViewScript, /可以继续补充票据报销/)
assert.match(
detailViewScript,
/\.\.\.buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)/
)
})
test('expense detail save is blocked while attachment recognition is running', () => {
@@ -335,21 +369,25 @@ test('expense detail save is blocked while attachment recognition is running', (
})
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_EXPENSE_TYPES = new Set\(\['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'\]\)/)
assert.match(detailViewScript, /const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set\(\['hotel_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, /return '起始地-目的地,例如:广州南-北京南'/)
assert.match(detailViewScript, /return '起始地-目的地'/)
assert.match(detailViewScript, /return '目的地酒店,例如:北京中心酒店'/)
assert.match(detailViewScript, /return '目的地酒店'/)
assert.match(detailViewScript, /isSyntheticLocationDisplay\(item\.detail, item\.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 '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'/
/isRouteDescriptionExpenseType\(expenseEditor\.itemType\)[\s\S]*!isValidRouteDescription\(expenseEditor\.itemReason\)[\s\S]*return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'/
)
assert.match(
detailViewScript,
/fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明,格式应为“始地-目的地”。`/
/fieldText === '行程说明格式错误'[\s\S]*return `\$\{labelPrefix\}的行程说明,格式应为“始地-目的地”。`/
)
})