feat: 增强差旅报销审核流程与票据智能推理
优化本体解析和编排器的差旅场景处理能力,完善报销单草稿 保存和费用明细同步逻辑,前端报销创建页面增加行程推理和 票据审核交互,新增助手会话快照工具函数,补充单元测试。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
99
web/src/utils/assistantSessionSnapshot.js
Normal file
99
web/src/utils/assistantSessionSnapshot.js
Normal 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)
|
||||
}
|
||||
@@ -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]) =>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,13 +288,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items,
|
||||
riskCards: [],
|
||||
sections: [
|
||||
{
|
||||
kind: 'completion',
|
||||
title: '建议补充字段',
|
||||
items
|
||||
}
|
||||
]
|
||||
sections: []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user