feat: 完善差旅票据行程提取与费用明细回填逻辑
增强文档智能识别的票据场景关键词和字段提取能力,优化 会话关联草稿报销单的解析路径,修复费用明细合并和票据 去重边界问题,前端改进报销创建和审批详情交互,补充单 元测试覆盖。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ? '删除中' : '删除' }}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user