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

@@ -77,7 +77,7 @@ const {
} = useApprovalInbox()
const sidebarMeta = {
overview: { label: '总览' },
overview: { label: '财务总览' },
workbench: { label: '个人工作台' },
requests: { label: '个人报销' },
approval: { label: '审批中心' },

View File

@@ -328,14 +328,17 @@
</div>
</details>
<div v-if="resolveReviewSubmitActions(message.reviewPayload).length || message.draftPayload?.claim_no" class="review-footer-actions">
<div
v-if="resolveReviewSubmitActions(message.reviewPayload).length || resolveReviewEditAction(message.reviewPayload) || message.draftPayload?.claim_no"
class="review-footer-actions"
>
<div class="review-footer-btn-row">
<button
v-for="action in resolveReviewSubmitActions(message.reviewPayload)"
:key="`${message.id}-${action.action_type}`"
type="button"
:class="['review-footer-btn', action.emphasis === 'primary' ? 'primary' : '']"
:disabled="reviewActionBusy"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click="handleReviewAction(message, action)"
>
{{ action.label || buildReviewPrimaryButtonLabel(message.reviewPayload, message.draftPayload) }}
@@ -344,11 +347,11 @@
<button
v-if="resolveReviewEditAction(message.reviewPayload)"
type="button"
class="review-footer-btn"
:disabled="reviewActionBusy"
:class="['review-footer-btn', resolveReviewEditAction(message.reviewPayload)?.emphasis === 'primary' ? 'primary' : '']"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click="handleReviewAction(message, resolveReviewEditAction(message.reviewPayload))"
>
修改识别信息
{{ resolveReviewEditAction(message.reviewPayload)?.label || '修改识别信息' }}
</button>
<button

View File

@@ -205,8 +205,13 @@
<td class="expense-desc col-desc">
<template v-if="editingExpenseId === item.id">
<div class="cell-editor">
<input v-model="expenseEditor.itemReason" class="editor-input" type="text" placeholder="输入费用说明" />
<span>业务报销说明</span>
<input
v-model="expenseEditor.itemReason"
class="editor-input"
type="text"
:placeholder="resolveExpenseReasonPlaceholder(expenseEditor.itemType)"
/>
<span>{{ resolveExpenseReasonHelper(expenseEditor.itemType) }}</span>
</div>
</template>
<template v-else>
@@ -325,7 +330,7 @@
<button
class="inline-action primary"
type="button"
:disabled="savingExpenseId === item.id || submitBusy || deleteBusy || deletingExpenseId === item.id"
:disabled="actionBusy"
@click="saveExpenseEdit(item)"
>
{{ savingExpenseId === item.id ? '保存中' : '保存' }}
@@ -333,7 +338,7 @@
<button
class="inline-action"
type="button"
:disabled="savingExpenseId === item.id || submitBusy || deleteBusy || deletingExpenseId === item.id"
:disabled="actionBusy"
@click="cancelExpenseEdit"
>
取消
@@ -341,7 +346,7 @@
<button
class="inline-action danger"
type="button"
:disabled="savingExpenseId === item.id || submitBusy || deleteBusy || deletingExpenseId === item.id"
:disabled="actionBusy"
@click="removeExpenseItem(item)"
>
{{ deletingExpenseId === item.id ? '删除中' : '删除' }}

View File

@@ -1296,6 +1296,65 @@ function buildReviewFormValues(fields) {
}, {})
}
function buildBusinessTimeContextFromReviewValues(values = {}) {
const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim()
if (!timeText) {
return null
}
const matchedDates = timeText.match(/\d{4}-\d{2}-\d{2}/g) || []
if (!matchedDates.length) {
return null
}
const startDate = matchedDates[0]
const endDate = matchedDates[matchedDates.length - 1] || startDate
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return null
}
const displayValue = startDate === endDate ? startDate : `${startDate}${endDate}`
return {
mode: startDate === endDate ? 'single' : 'range',
start_date: startDate,
end_date: endDate,
display_value: displayValue
}
}
function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return {}
}
const fallbackState = buildInlineReviewState(reviewPayload)
const candidateState = inlineState || fallbackState
const hasCandidateValue = Object.values(candidateState || {}).some((value) => {
if (typeof value === 'number') return value > 0
return Boolean(String(value || '').trim())
})
const state = hasCandidateValue ? candidateState : fallbackState
const fields = mergeInlineReviewFields(reviewPayload.edit_fields || [], state)
const values = buildReviewFormValues(fields)
const slotMap = buildReviewSlotMap(reviewPayload)
const inheritedTimeRange = String(
slotMap.time_range?.normalized_value ||
slotMap.time_range?.value ||
values.time_range ||
values.business_time ||
values.occurred_date ||
''
).trim()
if (inheritedTimeRange) {
values.time_range = values.time_range || inheritedTimeRange
values.business_time = values.business_time || inheritedTimeRange
}
const businessTimeContext = buildBusinessTimeContextFromReviewValues(values)
return {
review_form_values: values,
...(businessTimeContext ? { business_time_context: businessTimeContext } : {})
}
}
function buildReviewCorrectionMessage(fields) {
const lines = ['请按以下核对后的报销信息更新当前识别结果:']
for (const item of cloneReviewEditFields(fields)) {
@@ -4956,7 +5015,13 @@ export default {
}
function saveInlineReviewChanges() {
if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return
if (
!activeReviewPayload.value
|| !reviewHasUnsavedChanges.value
|| submitting.value
|| reviewActionBusy.value
|| sessionSwitchBusy.value
) return
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
return
@@ -5043,7 +5108,7 @@ export default {
}
async function submitComposer(options = {}) {
if (sessionSwitchBusy.value) return null
if (submitting.value || sessionSwitchBusy.value) return null
const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated)
@@ -5160,6 +5225,21 @@ export default {
if (resolvedUploadDisposition === 'continue_existing') {
extraContext.review_action = 'link_to_existing_draft'
const inheritedReviewContext = buildReviewFormContextFromPayload(
activeReviewPayload.value,
reviewInlineForm.value
)
if (inheritedReviewContext.review_form_values) {
extraContext.review_form_values = {
...inheritedReviewContext.review_form_values,
...(extraContext.review_form_values && typeof extraContext.review_form_values === 'object'
? extraContext.review_form_values
: {})
}
}
if (inheritedReviewContext.business_time_context && !extraContext.business_time_context) {
extraContext.business_time_context = inheritedReviewContext.business_time_context
}
effectiveFileNames = mergeUploadAttachmentNames(reviewAttachmentNames, fileNames)
effectiveOcrDocuments = mergeUploadOcrDocuments(
buildOcrDocumentsFromReviewPayload(activeReviewPayload.value),
@@ -5362,7 +5442,7 @@ export default {
async function handleReviewAction(message, action) {
const actionType = String(action?.action_type || '').trim()
if (!actionType || reviewActionBusy.value) return
if (!actionType || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) return
if (actionType === 'cancel_review') {
openCancelReviewDialog(message)

View File

@@ -50,16 +50,14 @@ const EXPENSE_TYPE_OPTIONS = [
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
'travel',
'train_ticket',
'flight_ticket',
'hotel_ticket',
'ride_ticket',
'meeting',
'entertainment'
])
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ride_ticket'])
const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/
function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
@@ -103,6 +101,23 @@ function resolveLocationDisplay(value, expenseType) {
return isPlaceholderValue(value) ? '待补充' : value
}
function isRouteDescriptionExpenseType(value) {
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function isValidRouteDescription(value) {
const text = String(value || '').trim()
return ROUTE_DESCRIPTION_PATTERN.test(text) && !/\d{4}[-/年.]\d{1,2}[-/月.]\d{1,2}/.test(text)
}
function resolveExpenseReasonPlaceholder(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'
}
function resolveExpenseReasonHelper(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地' : '业务报销说明'
}
function buildFallbackProgressSteps() {
return [
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
@@ -368,6 +383,8 @@ function buildExpenseDraftIssues(item) {
}
if (isPlaceholderValue(item.itemReason)) {
issues.push('缺少说明')
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
issues.push('行程说明格式错误')
}
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
issues.push('缺少地点')
@@ -464,6 +481,9 @@ function mapIssueToAdvice(issue) {
if (fieldText === '缺少说明') {
return `${labelPrefix}的用途说明。`
}
if (fieldText === '行程说明格式错误') {
return `${labelPrefix}的行程说明,格式应为“始发地-目的地”。`
}
if (fieldText === '缺少地点') {
return `${labelPrefix}的业务地点。`
}
@@ -1042,6 +1062,12 @@ export default {
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (
isRouteDescriptionExpenseType(expenseEditor.itemType)
&& !isValidRouteDescription(expenseEditor.itemReason)
) {
return '行程说明格式应为“始发地-目的地”,例如:广州南-北京南。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
@@ -1325,6 +1351,10 @@ export default {
}
async function saveExpenseEdit(item) {
if (actionBusy.value) {
toast(uploadingExpenseId.value ? '附件识别中,请等待识别完成后再保存。' : '当前操作处理中,请稍后再保存。')
return
}
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法保存费用明细。')
return
@@ -1666,6 +1696,8 @@ export default {
resolveAttachmentDisplayName,
resolveAttachmentPreviewTitle,
resolveAttachmentRecognition,
resolveExpenseReasonHelper,
resolveExpenseReasonPlaceholder,
resolveExpenseRiskState,
resolveExpenseIssues,
returnBusy,

View File

@@ -212,15 +212,15 @@ export function buildAttachmentRiskCards({
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
const claimCards = normalizedClaimRiskFlags
.map((flag, index) => {
.flatMap((flag, index) => {
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
return null
return []
}
if (!flag || typeof flag !== 'object') {
const risk = normalizeText(flag)
return risk
? {
? [{
id: `claim-risk-${index}`,
tone: 'medium',
label: '单据风险',
@@ -229,27 +229,38 @@ export function buildAttachmentRiskCards({
summary: '',
ruleBasis: ['系统预审规则命中该风险提示。'],
suggestion: '请结合业务背景补充说明或调整单据后再提交。'
}
: null
}]
: []
}
const tone = normalizeTone(flag.severity)
if (!['medium', 'high'].includes(tone)) {
return null
return []
}
return {
id: `claim-risk-${index}`,
const flagPoints = Array.isArray(flag.points)
? flag.points.map((point) => normalizeText(point)).filter(Boolean)
: []
const risks = flagPoints.length
? flagPoints
: [normalizeText(flag.message || flag.reason || flag.summary)].filter(Boolean)
const summary = normalizeText(flag.summary)
const ruleBasis = uniqueTexts([
...normalizeRuleBasis(flag.rule_basis || flag.ruleBasis),
summary ? `风险汇总:${summary}` : '',
'系统预审规则命中该风险提示。'
])
return risks.map((risk, pointIndex) => ({
id: `claim-risk-${index}-${pointIndex}`,
tone,
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
title: normalizeText(flag.label) || '单据风险提示',
risk: normalizeText(flag.message || flag.reason || flag.summary),
summary: normalizeText(flag.summary),
ruleBasis: normalizeRuleBasis(flag.rule_basis || flag.ruleBasis).length
? normalizeRuleBasis(flag.rule_basis || flag.ruleBasis)
: ['系统预审规则命中该风险提示。'],
risk,
summary,
ruleBasis,
suggestion: normalizeText(flag.suggestion) || '请结合业务背景补充说明或调整单据后再提交。'
}
}))
})
.filter(Boolean)
if (latestManualReturnCard) {

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/)