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

@@ -793,6 +793,133 @@
margin-top: 10px;
}
.message-suggested-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
padding: 10px;
border: 1px solid rgba(203, 213, 225, 0.72);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.98));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.78);
}
.message-suggested-action-btn {
position: relative;
min-height: 70px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr) 18px;
align-items: center;
gap: 10px;
padding: 12px 11px;
border: 1px solid rgba(203, 213, 225, 0.8);
border-radius: 10px;
background: rgba(255, 255, 255, 0.9);
color: #0f172a;
text-align: left;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition:
border-color 0.16s ease,
background 0.16s ease,
box-shadow 0.16s ease,
transform 0.16s ease;
}
.message-suggested-action-icon {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border-radius: 10px;
background: #f1f5f9;
color: #0f766e;
font-size: 18px;
box-shadow: inset 0 0 0 1px rgba(15, 118, 110, 0.08);
}
.message-suggested-action-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.message-suggested-action-title {
color: #0f172a;
font-size: var(--wb-fs-body);
font-weight: 850;
line-height: 1.25;
}
.message-suggested-action-btn small {
color: #64748b;
font-size: var(--wb-fs-caption);
font-weight: 650;
line-height: 1.35;
}
.message-suggested-action-arrow {
color: #94a3b8;
font-size: 15px;
justify-self: end;
transition: color 0.16s ease, transform 0.16s ease;
}
.message-suggested-action-btn:hover:not(:disabled) {
border-color: rgba(20, 184, 166, 0.72);
background: #ffffff;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.09);
transform: translateY(-1px);
}
.message-suggested-action-btn:hover:not(:disabled) .message-suggested-action-icon,
.message-suggested-action-btn:focus-visible .message-suggested-action-icon {
background: #ccfbf1;
color: #0f766e;
}
.message-suggested-action-btn:hover:not(:disabled) .message-suggested-action-arrow,
.message-suggested-action-btn:focus-visible .message-suggested-action-arrow {
color: #0f766e;
transform: translateX(2px);
}
.message-suggested-action-btn:focus-visible {
outline: 3px solid rgba(20, 184, 166, 0.18);
outline-offset: 2px;
border-color: #14b8a6;
}
.message-suggested-action-btn.selected {
border-color: rgba(13, 148, 136, 0.78);
background: #f0fdfa;
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.18);
}
.message-suggested-action-btn.selected .message-suggested-action-icon {
background: #99f6e4;
color: #115e59;
}
.message-suggested-action-btn.selected .message-suggested-action-arrow {
color: #0f766e;
}
.message-suggested-action-btn.locked:not(.selected) {
background: #f8fafc;
}
.message-suggested-action-btn:disabled {
cursor: not-allowed;
opacity: 0.62;
}
.message-suggested-action-btn.selected:disabled {
opacity: 1;
}
.message-meta-chip,
.capability-chip,
.risk-chip,
@@ -982,6 +1109,27 @@
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12);
}
.expense-query-record-list.compact .expense-query-record-card.selectable {
border-color: rgba(20, 184, 166, 0.35);
background: #ffffff;
}
.expense-query-record-list.compact .expense-query-record-card.selected {
border-color: rgba(13, 148, 136, 0.82);
background: #f0fdfa;
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.18);
}
.expense-query-record-list.compact .expense-query-record-card.locked:not(.selected) {
background: #f8fafc;
opacity: 0.58;
}
.expense-query-record-list.compact .expense-query-record-card:disabled {
cursor: not-allowed;
transform: none;
}
.expense-query-record-card > i {
color: #94a3b8;
font-size: 16px;
@@ -4775,6 +4923,10 @@
justify-self: stretch;
}
.message-suggested-actions {
grid-template-columns: 1fr;
}
.composer {
padding: 0 16px 16px;
}

View File

@@ -1586,6 +1586,11 @@
background: #fffcf7;
}
.validation-section--risk .risk-advice-card.low {
border-color: #dbeafe;
background: #f8fbff;
}
.validation-section--risk .risk-advice-card-head {
display: flex;
align-items: center;
@@ -1611,6 +1616,11 @@
color: #c2410c;
}
.validation-section--risk .risk-advice-card.low .risk-advice-card-head span {
background: #eff6ff;
color: #2563eb;
}
.validation-section--risk .risk-advice-card-head strong {
min-width: 0;
color: #0f172a;
@@ -1660,6 +1670,11 @@
background: #fffaf2;
}
.risk-advice-card.low {
border-color: #bfdbfe;
background: #f8fbff;
}
.risk-advice-card-head {
display: flex;
align-items: center;
@@ -1685,6 +1700,11 @@
color: #c2410c;
}
.risk-advice-card.low .risk-advice-card-head span {
background: #dbeafe;
color: #2563eb;
}
.risk-advice-card-head strong {
min-width: 0;
color: #0f172a;

View File

@@ -143,13 +143,17 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import robotAssistant from '../../assets/robot-helper.png'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
const props = defineProps({
showHeader: { type: Boolean, default: true },
@@ -164,11 +168,15 @@ const fileInputRef = ref(null)
const selectedFiles = ref([])
const pendingAction = ref('')
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const hasExpenseConversation = computed(() => Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId))
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|| hasLocalExpenseSnapshot.value
)
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const expenseActionIcon = computed(() => (hasExpenseConversation.value ? 'mdi mdi-history' : 'mdi mdi-magnify-scan'))
const assistantGreetingName = computed(() => {
@@ -271,6 +279,7 @@ function handleWorkbenchFilesChange(event) {
}
async function refreshLatestExpenseConversation() {
refreshLocalExpenseSnapshot()
try {
latestExpenseConversation.value = await loadLatestConversation()
} catch (error) {
@@ -279,6 +288,17 @@ async function refreshLatestExpenseConversation() {
}
}
function refreshLocalExpenseSnapshot() {
hasLocalExpenseSnapshot.value = hasAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
}
function handleAssistantSessionSnapshotChange(event) {
const sessionType = String(event?.detail?.sessionType || '').trim()
if (!sessionType || sessionType === SESSION_TYPE_EXPENSE) {
refreshLocalExpenseSnapshot()
}
}
async function clearKnowledgeHistoryBeforeExpense() {
await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
}
@@ -414,7 +434,13 @@ const policyItems = [
]
onMounted(() => {
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
onBeforeUnmount(() => {
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(

View File

@@ -0,0 +1,99 @@
const SNAPSHOT_VERSION = 1
const STORAGE_PREFIX = 'x-financial:assistant-session'
export const ASSISTANT_SESSION_SNAPSHOT_EVENT = 'x-financial-assistant-session-snapshot'
function normalizeSessionType(sessionType) {
return String(sessionType || 'expense').trim() || 'expense'
}
function normalizeUserId(userId) {
return String(userId || 'anonymous').trim() || 'anonymous'
}
export function resolveAssistantSessionSnapshotKey(userId, sessionType = 'expense') {
return `${STORAGE_PREFIX}:${normalizeUserId(userId)}:${normalizeSessionType(sessionType)}`
}
function getStorage() {
if (typeof window === 'undefined' || !window.localStorage) {
return null
}
return window.localStorage
}
function emitSnapshotChange(sessionType) {
if (typeof window === 'undefined') {
return
}
window.dispatchEvent(new CustomEvent(ASSISTANT_SESSION_SNAPSHOT_EVENT, {
detail: { sessionType: normalizeSessionType(sessionType) }
}))
}
export function readAssistantSessionSnapshot(userId, sessionType = 'expense') {
const storage = getStorage()
if (!storage) {
return null
}
try {
const rawValue = storage.getItem(resolveAssistantSessionSnapshotKey(userId, sessionType))
if (!rawValue) {
return null
}
const parsed = JSON.parse(rawValue)
if (!parsed || parsed.version !== SNAPSHOT_VERSION || !parsed.state) {
return null
}
return parsed
} catch (error) {
console.warn('Failed to read assistant session snapshot:', error)
return null
}
}
export function writeAssistantSessionSnapshot(userId, sessionType = 'expense', state = {}) {
const storage = getStorage()
if (!storage) {
return
}
const normalizedSessionType = normalizeSessionType(sessionType)
const snapshot = {
version: SNAPSHOT_VERSION,
updatedAt: Date.now(),
userId: normalizeUserId(userId),
sessionType: normalizedSessionType,
state
}
try {
storage.setItem(
resolveAssistantSessionSnapshotKey(userId, normalizedSessionType),
JSON.stringify(snapshot)
)
emitSnapshotChange(normalizedSessionType)
} catch (error) {
console.warn('Failed to write assistant session snapshot:', error)
}
}
export function clearAssistantSessionSnapshot(userId, sessionType = 'expense') {
const storage = getStorage()
if (!storage) {
return
}
const normalizedSessionType = normalizeSessionType(sessionType)
try {
storage.removeItem(resolveAssistantSessionSnapshotKey(userId, normalizedSessionType))
emitSnapshotChange(normalizedSessionType)
} catch (error) {
console.warn('Failed to clear assistant session snapshot:', error)
}
}
export function hasAssistantSessionSnapshot(userId, sessionType = 'expense') {
return Boolean(readAssistantSessionSnapshot(userId, sessionType)?.state)
}

View File

@@ -41,6 +41,10 @@ const FLOW_INTENT_KEYWORDS = {
explain: ['为什么', '依据', '规则', '怎么']
}
const EXPLICIT_EXPENSE_INTENT_PATTERN = /报销|报账|费用|发票|票据|单据|垫付|报销单|冲销|借款/
const NON_EXPENSE_INTENT_PATTERN = /怎么部署|如何部署|部署步骤|技术方案|排期|任务|工单|需求|代码|脚本|服务器配置|运维|实施计划|项目计划|会议纪要|周报|日报|总结/
const BUSINESS_ACTIVITY_PATTERN = /去|到|赴|前往|支撑|支持|部署|实施|驻场|出差|拜访|客户|项目|现场|电力|银行|医院|学校|园区|公司|集团|服务器/
function normalizeCompactText(value) {
return String(value || '').trim().replace(/\s+/g, '')
}
@@ -125,11 +129,74 @@ export function inferLocalFlowCandidates(rawText) {
}
}
export function shouldRequestExpenseSceneSelection(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
return false
}
if (String(options.reviewAction || '').trim()) {
return false
}
if (options.hasSelectedExpenseType) {
return false
}
const compact = normalizeCompactText(rawText)
if (!compact) {
return false
}
const hasExpenseIntent = /报销|报账|费用|申请/.test(compact)
if (!hasExpenseIntent) {
return false
}
const candidates = inferLocalFlowCandidates(rawText)
return !candidates.expenseType
}
export function shouldRequestExpenseIntentConfirmation(rawText, options = {}) {
if (options.sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return false
}
if (Number(options.attachmentCount || 0) > 0) {
return false
}
if (String(options.reviewAction || '').trim()) {
return false
}
if (options.hasConfirmedExpenseIntent || options.hasSelectedExpenseType) {
return false
}
const compact = normalizeCompactText(rawText)
if (!compact || compact.length < 6) {
return false
}
if (EXPLICIT_EXPENSE_INTENT_PATTERN.test(compact)) {
return false
}
if (NON_EXPENSE_INTENT_PATTERN.test(compact)) {
return false
}
return BUSINESS_ACTIVITY_PATTERN.test(compact)
}
export function buildLocalIntentPreview(rawText, sessionType = DEFAULT_SESSION_TYPE_EXPENSE, options = {}) {
if (sessionType === DEFAULT_SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
if (shouldRequestExpenseIntentConfirmation(rawText, { ...options, sessionType })) {
return '识别到业务事项描述,但是否发起报销尚不明确,需要先由用户确认'
}
if (shouldRequestExpenseSceneSelection(rawText, { ...options, sessionType })) {
return '初步识别为报销申请,但报销场景尚未明确,需要先由用户选择场景'
}
const compact = normalizeCompactText(rawText)
const intentLabels = options.intentLabels || DEFAULT_INTENT_LABELS
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>

View File

@@ -131,6 +131,33 @@
</span>
</div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.suggestedActions?.length"
class="message-suggested-actions"
>
<button
v-for="action in message.suggestedActions"
:key="`${message.id}-${action.action_type}-${action.label}`"
type="button"
class="message-suggested-action-btn"
:class="{
selected: isSuggestedActionSelected(message, action),
locked: message.suggestedActionsLocked
}"
:disabled="message.suggestedActionsLocked || submitting || reviewActionBusy || sessionSwitchBusy"
@click="handleSuggestedAction(message, action)"
>
<span class="message-suggested-action-icon" aria-hidden="true">
<i :class="action.icon || 'mdi mdi-shape-outline'"></i>
</span>
<span class="message-suggested-action-copy">
<span class="message-suggested-action-title">{{ action.label }}</span>
<small v-if="action.description">{{ action.description }}</small>
</span>
<i class="message-suggested-action-arrow mdi mdi-arrow-right" aria-hidden="true"></i>
</button>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
<strong>风险标签</strong>
<div class="message-detail-chip-row">
@@ -162,7 +189,9 @@
v-if="message.role === 'assistant' && !message.reviewPayload && message.queryPayload?.resultType === 'expense_claim_list'"
class="message-detail-block expense-query-block"
>
<strong>{{ message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细' }}</strong>
<strong>
{{ message.queryPayload.title || (message.queryPayload.selectionMode === 'draft_association' ? '选择关联草稿' : (message.queryPayload.recentWindowApplied ? '近 10 日单据' : '单据明细')) }}
</strong>
<p v-if="buildExpenseQueryWindowLabel(message.queryPayload)" class="expense-query-window-label">
{{ buildExpenseQueryWindowLabel(message.queryPayload) }}
@@ -185,7 +214,13 @@
:key="`${message.id}-${record.claimId}`"
type="button"
class="expense-query-record-card"
@click="openExpenseQueryRecord(record)"
:class="{
selectable: message.queryPayload.selectionMode === 'draft_association',
selected: message.selectedQueryRecordId === record.claimId || message.queryPayload.selectedClaimId === record.claimId,
locked: message.querySelectionLocked || message.queryPayload.selectionLocked
}"
:disabled="message.queryPayload.selectionMode === 'draft_association' && (message.querySelectionLocked || message.queryPayload.selectionLocked)"
@click="handleExpenseQueryRecordClick(message, record)"
>
<div class="expense-query-record-main">
<div class="expense-query-record-top">
@@ -244,7 +279,7 @@
<div v-else class="expense-query-empty">
<i class="mdi mdi-file-search-outline"></i>
<span>当前没有可直接展开的近期待办单据</span>
<span>{{ message.queryPayload.emptyText || '当前没有可直接展开的近期待办单据。' }}</span>
</div>
<p v-if="buildExpenseQueryHint(message.queryPayload)" class="expense-query-hint">

View File

@@ -335,14 +335,6 @@
>
{{ savingExpenseId === item.id ? '保存中' : '保存' }}
</button>
<button
class="inline-action"
type="button"
:disabled="actionBusy"
@click="cancelExpenseEdit"
>
取消
</button>
<button
class="inline-action danger"
type="button"

File diff suppressed because it is too large Load Diff

View File

@@ -56,7 +56,8 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
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_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_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) {
@@ -93,29 +94,59 @@ function resolveLocationSummaryLabel(value) {
return isLocationRequiredExpenseType(value) ? '业务地点' : '采购/收货地点'
}
function resolveLocationDisplay(value, expenseType) {
if (!isLocationRequiredExpenseType(expenseType) && isPlaceholderValue(value)) {
return '非必填'
}
return isPlaceholderValue(value) ? '待补充' : value
}
function isRouteDescriptionExpenseType(value) {
return ROUTE_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function isHotelDescriptionExpenseType(value) {
return HOTEL_DESCRIPTION_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
function resolveExpenseDetailHint(expenseType) {
if (isRouteDescriptionExpenseType(expenseType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(expenseType)) {
return '目的地酒店'
}
if (!isLocationRequiredExpenseType(expenseType)) {
return '非必填'
}
return '待补充'
}
function resolveLocationDisplay(value, expenseType) {
return isPlaceholderValue(value) ? resolveExpenseDetailHint(expenseType) : value
}
function isSyntheticLocationDisplay(value, expenseType) {
const text = String(value || '').trim()
return ['待补充', '非必填', resolveExpenseDetailHint(expenseType)].includes(text)
}
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) ? '始发地-目的地,例如:广州南-北京南' : '输入费用说明'
if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地,例如:广州南-北京南'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店,例如:北京中心酒店'
}
return '输入费用说明'
}
function resolveExpenseReasonHelper(itemType) {
return isRouteDescriptionExpenseType(itemType) ? '始发地-目的地' : '业务报销说明'
if (isRouteDescriptionExpenseType(itemType)) {
return '起始地-目的地'
}
if (isHotelDescriptionExpenseType(itemType)) {
return '目的地酒店'
}
return '业务报销说明'
}
function buildFallbackProgressSteps() {
@@ -399,6 +430,46 @@ function buildExpenseDraftIssues(item) {
return issues
}
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||
requestModel?.typeCode === 'travel' ||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
if (!isTravelContext) {
return []
}
const hasUploadedType = (itemType) =>
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
const cards = []
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
})
}
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
})
}
return cards
}
function buildDraftBlockingIssues(request, expenseItems) {
const issues = []
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
@@ -482,7 +553,7 @@ function mapIssueToAdvice(issue) {
return `${labelPrefix}的用途说明。`
}
if (fieldText === '行程说明格式错误') {
return `${labelPrefix}的行程说明,格式应为“始地-目的地”。`
return `${labelPrefix}的行程说明,格式应为“始地-目的地”。`
}
if (fieldText === '缺少地点') {
return `${labelPrefix}的业务地点。`
@@ -987,11 +1058,14 @@ export default {
const aiAdvice = computed(() => {
const completionItems = draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
const riskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
})
const riskCards = [
...buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: request.value.riskFlags || request.value.risk_flags_json || []
}),
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
]
return buildAiAdviceViewModel({
completionItems,
@@ -1029,6 +1103,17 @@ export default {
}
}
function populateExpenseEditor(item) {
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (isSyntheticLocationDisplay(item.detail, item.itemType) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function startExpenseEdit(item) {
if (!isEditableRequest.value || actionBusy.value) {
return
@@ -1038,40 +1123,31 @@ export default {
return
}
editingExpenseId.value = item.id
expenseEditor.itemDate = item.itemDate || ''
expenseEditor.itemType = item.itemType || 'other'
expenseEditor.itemReason = item.itemReason || (item.desc === '待补充' ? '' : item.desc)
expenseEditor.itemLocation =
item.itemLocation || (['待补充', '非必填'].includes(item.detail) ? '' : item.detail)
expenseEditor.itemAmount = item.itemAmount ? String(item.itemAmount) : ''
expenseEditor.invoiceId = item.invoiceId || ''
}
function cancelExpenseEdit() {
editingExpenseId.value = ''
populateExpenseEditor(item)
}
function validateExpenseEditor() {
if (!isValidIsoDate(expenseEditor.itemDate)) {
if (expenseEditor.itemDate && !isValidIsoDate(expenseEditor.itemDate)) {
return '请输入正确的费用日期,格式为 YYYY-MM-DD。'
}
if (isPlaceholderValue(expenseEditor.itemType)) {
return '请选择费用项目。'
}
if (isPlaceholderValue(expenseEditor.itemReason)) {
return '请输入费用说明。'
}
if (
!isPlaceholderValue(expenseEditor.itemReason)
&&
isRouteDescriptionExpenseType(expenseEditor.itemType)
&& !isValidRouteDescription(expenseEditor.itemReason)
) {
return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'
return '行程说明格式应为“始地-目的地”,例如:广州南-北京南。'
}
const amount = Number(expenseEditor.itemAmount)
if (!Number.isFinite(amount) || amount <= 0) {
return '请输入大于 0 的费用金额。'
const amountText = String(expenseEditor.itemAmount || '').trim()
if (amountText) {
const amount = Number(amountText)
if (!Number.isFinite(amount) || amount < 0) {
return '请输入不小于 0 的费用金额。'
}
}
return ''
}
@@ -1223,10 +1299,26 @@ export default {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
const recognizedItemType = String(payload?.item_type ?? payload?.itemType ?? '').trim()
const recognizedItemReason = String(payload?.item_reason ?? payload?.itemReason ?? '').trim()
const recognizedItemLocation = String(payload?.item_location ?? payload?.itemLocation ?? '').trim()
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
}
if (recognizedItemDate) {
itemPatch.itemDate = recognizedItemDate
}
if (recognizedItemType) {
itemPatch.itemType = recognizedItemType
}
if (recognizedItemReason) {
itemPatch.itemReason = recognizedItemReason
}
if (recognizedItemLocation) {
itemPatch.itemLocation = recognizedItemLocation
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
@@ -1234,12 +1326,7 @@ export default {
applyLocalExpenseItemPatch(item.id, {
...itemPatch
})
if (editingExpenseId.value === item.id) {
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
expenseEditor.itemAmount = String(recognizedItemAmount)
}
}
populateExpenseEditor({ ...item, ...itemPatch })
emit('request-updated', { claimId: request.value.claimId })
const riskNotice = buildAttachmentRiskNotice(payload?.attachment)
@@ -1370,20 +1457,25 @@ export default {
try {
const nextInvoiceId = expenseEditor.invoiceId.trim()
const preservedLocation = String(item.itemLocation || expenseEditor.itemLocation || '').trim()
await updateExpenseClaimItem(request.value.claimId, item.id, {
item_date: expenseEditor.itemDate,
const amountText = String(expenseEditor.itemAmount || '').trim()
const nextAmount = amountText ? Number(amountText) : 0
const itemPayload = {
item_type: expenseEditor.itemType,
item_reason: expenseEditor.itemReason.trim(),
item_location: preservedLocation,
item_amount: Number(expenseEditor.itemAmount),
item_amount: nextAmount,
invoice_id: nextInvoiceId
})
}
if (expenseEditor.itemDate) {
itemPayload.item_date = expenseEditor.itemDate
}
await updateExpenseClaimItem(request.value.claimId, item.id, itemPayload)
applyLocalExpenseItemPatch(item.id, {
itemDate: expenseEditor.itemDate,
itemDate: expenseEditor.itemDate || item.itemDate,
itemType: expenseEditor.itemType,
itemReason: expenseEditor.itemReason.trim(),
itemLocation: preservedLocation,
itemAmount: Number(expenseEditor.itemAmount),
itemAmount: nextAmount,
invoiceId: nextInvoiceId
})
let riskNotice = ''
@@ -1713,7 +1805,6 @@ export default {
triggerExpenseUpload,
uploadedExpenseCount,
uploadingExpenseId,
cancelExpenseEdit,
saveExpenseEdit
}
}

View File

@@ -288,13 +288,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。',
items,
riskCards: [],
sections: [
{
kind: 'completion',
title: '建议补充字段',
items
}
]
sections: []
}
}

View File

@@ -5,6 +5,8 @@ import {
buildLocalExtractionProgressMessages,
buildLocalIntentPreview,
inferLocalFlowCandidates,
shouldRequestExpenseIntentConfirmation,
shouldRequestExpenseSceneSelection,
summarizeSemanticIntentDetail
} from '../src/utils/reimbursementTextInference.js'
@@ -40,3 +42,32 @@ test('semantic intent detail includes recognized expense type', () => {
'已识别为报销场景,当前目标是草稿生成,费用类型为交通费'
)
})
test('ambiguous expense prompt waits for scene selection before extraction preview', () => {
const ambiguousMessage = '业务发生时间:2026-02-20 至 2026-02-23去上海支持上海电力部署项目申请报销'
assert.equal(shouldRequestExpenseSceneSelection(ambiguousMessage), true)
assert.match(buildLocalIntentPreview(ambiguousMessage), /需要先由用户选择场景/)
assert.doesNotMatch(buildLocalIntentPreview(ambiguousMessage), /草稿生成/)
})
test('clear expense type does not request scene selection', () => {
const travelMessage = '业务发生时间:2026-02-20 至 2026-02-23去上海出差支持上海电力部署项目申请差旅报销'
assert.equal(shouldRequestExpenseSceneSelection(travelMessage), false)
assert.match(buildLocalIntentPreview(travelMessage), /差旅费/)
})
test('business activity without expense intent asks for reimbursement confirmation first', () => {
const businessMessage = '去上海电力支撑项目部署'
assert.equal(shouldRequestExpenseIntentConfirmation(businessMessage), true)
assert.match(buildLocalIntentPreview(businessMessage), /是否发起报销尚不明确/)
assert.equal(shouldRequestExpenseSceneSelection(businessMessage), false)
})
test('explicit technical operation does not ask for reimbursement confirmation', () => {
const operationMessage = '去上海电力支撑项目部署,帮我整理服务器部署步骤'
assert.equal(shouldRequestExpenseIntentConfirmation(operationMessage), false)
})

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\}的行程说明,格式应为“始地-目的地”。`/
)
})