feat: 增加差旅报销标准测算和财务终审流程

新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 09:28:33 +08:00
parent 002bf4f756
commit 8f65661809
43 changed files with 4366 additions and 410 deletions

View File

@@ -121,7 +121,14 @@
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
<span
v-for="item in message.meta"
:key="item"
class="message-meta-chip"
:class="message.metaTone"
>
{{ item }}
</span>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
@@ -548,6 +555,72 @@
</div>
</div>
</div>
<div class="travel-calculator-anchor">
<button
type="button"
class="tool-btn composer-side-btn travel-calculator-trigger"
:class="{ active: travelCalculatorOpen }"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="差旅计算器"
title="差旅计算器"
:aria-expanded="travelCalculatorOpen"
@click.stop="toggleTravelCalculator"
>
<i class="mdi mdi-calculator"></i>
</button>
<div
v-if="travelCalculatorOpen"
class="travel-calculator-popover"
role="dialog"
aria-label="差旅计算器"
@click.stop
>
<div class="travel-calculator-mini-head">
<strong>差旅计算器</strong>
<span>按规则中心差旅表测算</span>
</div>
<div class="travel-calculator-form">
<label class="travel-calculator-field">
<span>实际天数</span>
<input
v-model="travelCalculatorForm.days"
type="number"
min="1"
step="1"
inputmode="numeric"
:disabled="travelCalculatorBusy"
@keydown.enter.prevent="submitTravelCalculator"
/>
</label>
<label class="travel-calculator-field">
<span>出差地点</span>
<input
v-model="travelCalculatorForm.location"
type="text"
placeholder="例如:北京、成都"
:disabled="travelCalculatorBusy"
@keydown.enter.prevent="submitTravelCalculator"
/>
</label>
</div>
<p v-if="travelCalculatorError" class="travel-calculator-error">
{{ travelCalculatorError }}
</p>
<div class="composer-date-popover-actions">
<button type="button" class="composer-date-cancel-btn" :disabled="travelCalculatorBusy" @click="closeTravelCalculator">
取消
</button>
<button
type="button"
class="composer-date-apply-btn"
:disabled="!travelCalculatorCanSubmit"
@click="submitTravelCalculator"
>
{{ travelCalculatorBusy ? '计算中...' : 'AI计算' }}
</button>
</div>
</div>
</div>
</div>
<div class="composer-shell">
@@ -783,7 +856,8 @@
:class="{
editable: item.editor,
editing: reviewInlineEditorKey === item.key,
invalid: Boolean(reviewInlineErrors[item.key])
invalid: Boolean(reviewInlineErrors[item.key]),
wide: item.wide
}"
@click="openInlineReviewEditor(item.key)"
>
@@ -831,6 +905,19 @@
@keydown.enter.prevent="commitInlineReviewEditor"
/>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'textarea'">
<textarea
v-model="reviewInlineForm[item.modelKey]"
class="review-inline-input review-inline-textarea"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
:placeholder="item.placeholder"
rows="3"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.stop
></textarea>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'select'">
<div class="review-inline-select-list" @click.stop>
<button
@@ -1091,12 +1178,9 @@
<section class="review-side-card review-side-risk-card">
<div class="review-side-head">
<div class="review-side-head-copy">
<strong>合规提 / 风险评分</strong>
<p>结合本体附件要求和识别结果集中查看当前票据风险</p>
<strong>差旅合规提</strong>
<p>结合票据识别结果与差旅规则逐项查看需要处理的风险</p>
</div>
<span class="review-side-risk-score" :class="{ empty: reviewRiskScore === null }">
{{ reviewRiskScore === null ? '无' : `${reviewRiskScore}/100` }}
</span>
</div>
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
@@ -1106,9 +1190,9 @@
type="button"
class="review-side-risk-item"
:class="item.level"
@click="openReviewRiskDetail(item)"
@click="appendReviewRiskBriefToConversation(item)"
>
<span class="review-side-risk-icon">
<span class="review-side-risk-icon" :title="item.levelLabel">
<i :class="item.icon"></i>
</span>
<span class="review-side-risk-copy">
@@ -1116,7 +1200,6 @@
<p>{{ item.summary }}</p>
</span>
<span class="review-side-risk-meta">
{{ item.levelLabel }}
<i class="mdi mdi-chevron-right"></i>
</span>
</button>
@@ -1125,8 +1208,8 @@
<span class="review-side-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
</span>
<strong>暂无风险评分</strong>
<p>当前版本还没有返回结构化风险评分结果这里先不展示虚拟分数</p>
<strong>暂无风险提示</strong>
<p>当前没有需要额外处理的结构化风险点</p>
</div>
</section>
</template>
@@ -1222,41 +1305,6 @@
@confirm="confirmCancelReview"
/>
<Transition name="assistant-modal">
<div v-if="reviewRiskDetailDialog.open" class="assistant-overlay review-overlay">
<section class="review-risk-detail-modal">
<header class="review-risk-detail-head">
<div>
<span class="assistant-badge warning">{{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }}</span>
<h3>{{ reviewRiskDetailDialog.item?.title || '风险提示' }}</h3>
</div>
<button class="close-btn" type="button" aria-label="关闭风险说明" @click="closeReviewRiskDetail">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="review-risk-detail-body">
<div class="review-risk-detail-level" :class="reviewRiskDetailDialog.item?.level">
<i :class="reviewRiskDetailDialog.item?.icon || 'mdi mdi-information-outline'"></i>
<span>{{ reviewRiskDetailDialog.item?.levelLabel || '提示' }}</span>
</div>
<article class="review-risk-detail-section">
<strong>提示情况</strong>
<p>{{ reviewRiskDetailDialog.item?.summary }}</p>
</article>
<article class="review-risk-detail-section">
<strong>详细解释</strong>
<p>{{ reviewRiskDetailDialog.item?.detail }}</p>
</article>
<article class="review-risk-detail-section">
<strong>处理建议</strong>
<p>{{ reviewRiskDetailDialog.item?.suggestion }}</p>
</article>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">

View File

@@ -375,15 +375,15 @@
</article>
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
<h3>领导意见</h3>
<h3>{{ approvalOpinionTitle }}</h3>
<textarea
v-model="leaderOpinion"
maxlength="500"
placeholder="请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。"
aria-label="领导意见"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
<div class="leader-opinion-meta">
<span>审批通过后将流转至财务审批</span>
<span>{{ approvalOpinionHint }}</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</article>
@@ -620,10 +620,10 @@
<ConfirmDialog
:open="approveConfirmDialogOpen"
badge="领导审批"
:badge="approvalConfirmBadge"
badge-tone="info"
:title="`确认通过 ${request.id} 吗?`"
description="确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。"
:description="approvalConfirmDescription"
cancel-text="返回核对"
confirm-text="确认通过"
busy-text="通过中..."
@@ -644,10 +644,10 @@
</div>
<div class="submit-confirm-row">
<span>下一节点</span>
<strong>财务审批</strong>
<strong>{{ approvalNextStage }}</strong>
</div>
<div class="submit-confirm-row">
<span>领导意见</span>
<span>{{ approvalOpinionTitle }}</span>
<strong>{{ leaderOpinion.trim() || '未填写' }}</strong>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import {
TRANSPORT_KEYWORD_PATTERN
} from '../../utils/reimbursementTextInference.js'
import {
calculateTravelReimbursement,
fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta,
@@ -55,15 +56,15 @@ const REVIEW_RISK_LEVEL_META = {
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
warning: {
label: '需关注',
medium: {
label: '中风险',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
low: {
label: '低风险',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
}
}
@@ -310,6 +311,7 @@ const FLOW_MISSING_SLOT_LABELS = {
participants: '参与人员',
attachments: '票据附件'
}
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
let messageSeed = 0
function nowTime() {
@@ -1317,6 +1319,7 @@ function createEmptyInlineReviewState() {
return {
occurred_date: '',
amount: '',
transport_type: '',
scene_label: '',
reason_value: '',
customer_name: '',
@@ -1330,6 +1333,67 @@ function createEmptyInlineReviewState() {
}
}
function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const expenseType = resolveExpenseTypeCode(
inlineState?.expense_type ||
buildReviewSlotMap(reviewPayload).expense_type?.normalized_value ||
buildReviewSlotMap(reviewPayload).expense_type?.value ||
''
)
if (['travel', 'hotel', 'transport'].includes(expenseType)) {
return true
}
return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
return (
['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) ||
['travel', 'hotel', 'transport'].includes(suggestedType)
)
})
}
function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const labels = []
const appendLabel = (label) => {
if (label && !labels.includes(label)) {
labels.push(label)
}
}
for (const item of documents) {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const text = [
item?.filename,
item?.summary,
item?.scene_label,
item?.suggested_expense_type,
...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : [])
].join(' ')
const compact = text.replace(/\s+/g, '')
if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) {
appendLabel('飞机')
} else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) {
appendLabel('火车/高铁')
} else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) {
appendLabel('打车/网约车')
}
}
const fallback = String(fallbackText || '').replace(/\s+/g, '')
if (!labels.length) {
if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机')
if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁')
if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车')
}
return labels.join('、')
}
function buildClientTimeContext() {
const now = new Date()
const locale =
@@ -1434,7 +1498,11 @@ function resolveReviewMissingSlotCards(reviewPayload) {
}
function resolveReviewRiskBriefs(reviewPayload) {
return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => {
const title = String(item?.title || '').trim()
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
})
}
function formatConfidenceLabel(value) {
@@ -1792,7 +1860,7 @@ function buildReviewAlertChips(reviewPayload) {
chips.push({
key: item.key,
label: buildReviewAlertLabel(item.key, expenseTypeLabel),
tone: item.key === 'attachments' ? 'danger' : 'warning'
tone: 'warning'
})
}
@@ -1830,7 +1898,7 @@ function buildReviewTodoItems(reviewPayload) {
title: config.title || item.label,
hint: item.hint || config.hint || `请补充${item.label}`,
status: config.status || '待补充',
tone: item.key === 'attachments' ? 'danger' : 'warning'
tone: 'warning'
}
})
}
@@ -2075,6 +2143,9 @@ function buildInlineReviewState(reviewPayload) {
editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
).trim()
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
const transportType = String(
editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue)
).trim()
return {
occurred_date: String(
@@ -2083,6 +2154,7 @@ function buildInlineReviewState(reviewPayload) {
amount: normalizeAmountValue(
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
),
transport_type: transportType,
scene_label: sceneLabel,
reason_value:
sceneLabel === REVIEW_SCENE_OTHER_OPTION
@@ -2129,6 +2201,56 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
: totalAttachmentCount > 0
? `已上传 ${totalAttachmentCount}`
: buildReviewAttachmentStatus(reviewPayload)
if (isTravelReviewPayload(reviewPayload, inlineState)) {
return [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'transport_type',
label: '交通类型',
value: String(inlineState.transport_type || '').trim() || '待确认',
icon: 'mdi mdi-train-car',
editor: 'text',
modelKey: 'transport_type',
placeholder: '例如 火车/高铁、飞机'
},
{
key: 'hotel_name',
label: '酒店名称',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-bed-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店名称'
},
{
key: 'travel_purpose',
label: '出差事宜',
value: String(inlineState.reason_value || '').trim() || '待补充',
icon: 'mdi mdi-briefcase-edit-outline',
editor: 'textarea',
modelKey: 'reason_value',
placeholder: '请填写本次出差的具体工作内容或业务意图',
wide: true
}
]
}
const cards = [
{
key: 'occurred_date',
@@ -2319,14 +2441,6 @@ function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInli
)
}
function buildReviewRiskScore(reviewPayload) {
const score = Number(reviewPayload?.risk_score)
if (!Number.isFinite(score) || score <= 0) {
return null
}
return Math.max(0, Math.min(100, Math.round(score)))
}
function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
if (slotKey === 'customer_name') {
return expenseTypeLabel === '业务招待费'
@@ -2353,17 +2467,30 @@ function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
function buildReviewRiskSummary(reviewPayload) {
if (resolveReviewRiskBriefs(reviewPayload).length) {
return '当前识别到了合规提醒,提交前建议逐项核对。'
return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。'
}
return '当前版本暂未生成风险评分结果。'
return '当前没有需要额外处理的结构化风险点。'
}
function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'medium') return 'warning'
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
return 'info'
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low'
if (normalized === 'high') return normalized
return 'low'
}
function normalizeReviewRiskTitle(title, fallbackTitle) {
const normalized = String(title || '').trim()
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
if (!normalized) return fallback
const cleaned = normalized
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
.replace(/(高风险|中风险|低风险)/g, '')
.replace(/^[:\-—\s]+|[:\-—\s]+$/g, '')
.trim()
return cleaned || fallback
}
function buildReviewRiskItems(reviewPayload) {
@@ -2374,9 +2501,9 @@ function buildReviewRiskItems(reviewPayload) {
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = title || fallbackTitle
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
@@ -2389,12 +2516,30 @@ function buildReviewRiskItems(reviewPayload) {
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
sourceLabel: meta.label,
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
.slice(0, 6)
}
function buildReviewRiskConversationText(item) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const lines = [`${title}`]
if (summary) {
lines.push('', `风险点:${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `修改建议:${suggestion}`)
}
return lines.join('\n')
}
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
@@ -2489,6 +2634,7 @@ function normalizeInlineReviewComparableState(state) {
return {
occurred_date: String(source.occurred_date || '').trim(),
amount: String(source.amount || '').trim(),
transport_type: String(source.transport_type || '').trim(),
scene_label: String(source.scene_label || '').trim(),
reason_value: String(source.reason_value || '').trim(),
customer_name: String(source.customer_name || '').trim(),
@@ -2512,6 +2658,9 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
if (base.amount !== next.amount) {
lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`)
}
if (base.transport_type !== next.transport_type) {
lines.push(`交通类型 ${next.transport_type || '待确认'}`)
}
if (base.scene_label !== next.scene_label) {
lines.push(`场景 ${next.scene_label || '待补充'}`)
}
@@ -2543,6 +2692,7 @@ function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = [])
const fieldConfigs = [
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
{ key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' },
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
@@ -2611,6 +2761,7 @@ function mergeInlineReviewFields(baseFields, inlineState) {
const merged = cloneReviewEditFields(baseFields)
const updateMap = {
expense_type: inlineState.expense_type,
transport_type: inlineState.transport_type,
occurred_date: inlineState.occurred_date,
amount: inlineState.amount,
customer_name: inlineState.customer_name,
@@ -2699,7 +2850,7 @@ function buildReviewRiskHint(reviewPayload) {
if (!riskBriefs.length) {
return ''
}
return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。'
return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。'
}
function buildReviewActionHint(reviewPayload) {
@@ -2839,6 +2990,14 @@ export default {
const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([])
const composerBusinessTimeDraftTouched = ref(false)
const travelCalculatorOpen = ref(false)
const travelCalculatorBusy = ref(false)
const travelCalculatorError = ref('')
const travelCalculatorResult = ref(null)
const travelCalculatorForm = ref({
days: '1',
location: ''
})
const attachedFiles = ref([])
const composerFilesExpanded = ref(false)
const submitting = ref(false)
@@ -2882,10 +3041,6 @@ export default {
const activeReviewDocumentIndex = ref(0)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const insightPanelCollapsed = ref(false)
const reviewRiskDetailDialog = ref({
open: false,
item: null
})
const documentPreviewDialog = ref({
open: false,
filename: '',
@@ -2921,6 +3076,11 @@ export default {
&& composerRangeStartDate.value <= composerRangeEndDate.value
)
})
const travelCalculatorCanSubmit = computed(() =>
!travelCalculatorBusy.value
&& Number(travelCalculatorForm.value.days) >= 1
&& Boolean(String(travelCalculatorForm.value.location || '').trim())
)
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const completedFlowStepCount = computed(
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
@@ -3040,10 +3200,9 @@ export default {
).length > 0
)
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value))
const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value))
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
@@ -3301,7 +3460,9 @@ export default {
activeReviewDocumentIndex.value = nextDocumentDrafts.length
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
: 0
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
? REVIEW_DRAWER_MODE_RISK
: REVIEW_DRAWER_MODE_REVIEW
reviewInlinePendingFiles.value = []
reviewInlineEditorKey.value = ''
reviewInlineErrors.value = {}
@@ -3975,6 +4136,9 @@ export default {
function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value
if (composerDatePickerOpen.value) {
travelCalculatorOpen.value = false
}
}
function closeComposerDatePicker() {
@@ -3998,13 +4162,21 @@ export default {
}
function handleComposerDatePickerOutside(event) {
if (!composerDatePickerOpen.value) {
if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
return
}
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
return
}
composerDatePickerOpen.value = false
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
return
}
if (composerDatePickerOpen.value) {
composerDatePickerOpen.value = false
}
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
travelCalculatorOpen.value = false
}
}
async function applyComposerDateSelection() {
@@ -4026,6 +4198,142 @@ export default {
composerTextareaRef.value?.focus()
}
function resolveTravelCalculatorInitialDays() {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return 1
}
const startDate = businessTimeContext.start_date
const endDate = businessTimeContext.end_date || startDate
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return 1
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 1
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function resolveTravelCalculatorInitialLocation() {
const slotMap = buildReviewSlotMap(activeReviewPayload.value)
const candidates = [
reviewInlineForm.value.location,
slotMap.business_location?.normalized_value,
slotMap.business_location?.value,
slotMap.location?.normalized_value,
slotMap.location?.value,
currentUser.value?.location
]
return String(candidates.find((item) => String(item || '').trim()) || '').trim()
}
function openTravelCalculator() {
closeComposerDatePicker()
travelCalculatorError.value = ''
travelCalculatorResult.value = null
travelCalculatorForm.value = {
days: String(resolveTravelCalculatorInitialDays()),
location: resolveTravelCalculatorInitialLocation()
}
travelCalculatorOpen.value = true
}
function toggleTravelCalculator() {
if (travelCalculatorOpen.value) {
closeTravelCalculator()
return
}
openTravelCalculator()
}
function closeTravelCalculator() {
if (travelCalculatorBusy.value) {
return
}
travelCalculatorOpen.value = false
}
function formatTravelCalculatorMoney(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return String(value || '0')
}
return amount.toFixed(2)
}
function buildTravelCalculatorResultText(result) {
const days = Number(result?.days) || 1
const location = String(result?.location || '').trim() || '未填写地点'
const matchedCity = String(result?.matched_city || location).trim()
const grade = String(result?.grade || '').trim() || '当前职级'
const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位'
const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域'
const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则'
const ruleVersion = String(result?.rule_version || '').trim()
const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate)
const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount)
const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate)
const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate)
const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate)
const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount)
const totalAmount = formatTravelCalculatorMoney(result?.total_amount)
const ruleVersionText = ruleVersion ? `${ruleVersion}` : ''
const user = currentUser.value || {}
const displayName = String(user.name || user.display_name || user.username || '').trim()
const greeting = displayName ? `您好,${displayName}` : '您好,'
return [
`${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`,
'',
`**参考可报销合计:${totalAmount} 元**`,
'',
'| 项目 | 标准口径 | 天数 | 小计 |',
'| --- | --- | ---: | ---: |',
`| 住宿费 | ${matchedCity} / ${grade}${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`,
`| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`,
'',
'**计算过程**',
`1. 住宿费:${hotelRate} × ${days} = ${hotelAmount}`,
`2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount}`,
`3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount}`,
'',
`**规则依据**${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`,
'',
'这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。'
].join('\n')
}
async function submitTravelCalculator() {
if (!travelCalculatorCanSubmit.value) {
travelCalculatorError.value = '请填写出差天数和地点后再计算。'
return
}
travelCalculatorBusy.value = true
travelCalculatorError.value = ''
try {
const user = currentUser.value || {}
const payload = await calculateTravelReimbursement({
days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1),
location: String(travelCalculatorForm.value.location || '').trim(),
grade: String(user.grade || '').trim()
})
travelCalculatorResult.value = payload
messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], {
meta: ['差旅计算器'],
metaTone: 'low'
}))
travelCalculatorOpen.value = false
nextTick(scrollToBottom)
} catch (error) {
travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。'
} finally {
travelCalculatorBusy.value = false
}
}
function rememberFilePreviews(filePreviews) {
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
}
@@ -4378,6 +4686,7 @@ export default {
...reviewInlineForm.value,
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
amount: String(reviewInlineForm.value.amount || '').trim(),
transport_type: String(reviewInlineForm.value.transport_type || '').trim(),
customer_name: String(reviewInlineForm.value.customer_name || '').trim(),
location: String(reviewInlineForm.value.location || '').trim(),
merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(),
@@ -4473,19 +4782,13 @@ export default {
})
}
function openReviewRiskDetail(item) {
function appendReviewRiskBriefToConversation(item) {
if (!item) return
reviewRiskDetailDialog.value = {
open: true,
item
}
}
function closeReviewRiskDetail() {
reviewRiskDetailDialog.value = {
...reviewRiskDetailDialog.value,
open: false
}
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low'
}))
nextTick(scrollToBottom)
}
function goReviewDocument(direction) {
@@ -5267,11 +5570,9 @@ export default {
REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible,
reviewPanelConfidence,
reviewRiskScore,
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewRiskDetailDialog,
recognizedNarratives,
reviewRecognitionNotes,
reviewDocumentSummaries,
@@ -5281,6 +5582,12 @@ export default {
reviewCancelDialogOpen,
reviewEditDialogOpen,
uploadDecisionDialogOpen,
travelCalculatorOpen,
travelCalculatorBusy,
travelCalculatorError,
travelCalculatorResult,
travelCalculatorForm,
travelCalculatorCanSubmit,
deleteSessionDialogOpen,
reviewActionBusy,
deleteSessionBusy,
@@ -5331,6 +5638,10 @@ export default {
resolveFlowStepStatusLabel,
resolveFlowStepDetail,
toggleInsightPanel,
openTravelCalculator,
toggleTravelCalculator,
closeTravelCalculator,
submitTravelCalculator,
switchToReviewOverviewDrawer,
toggleReviewDocumentDrawer,
toggleReviewRiskDrawer,
@@ -5357,8 +5668,7 @@ export default {
selectReviewCategory,
selectReviewOtherCategory,
queryDraftByClaimNo,
openReviewRiskDetail,
closeReviewRiskDetail,
appendReviewRiskBriefToConversation,
goReviewDocument,
openActiveReviewDocumentPreview,
closeDocumentPreview,

View File

@@ -17,7 +17,12 @@ import {
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isFinanceUser
} from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
@@ -82,7 +87,7 @@ function resolveLocationDisplay(value, expenseType) {
function buildFallbackProgressSteps() {
return [
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
{ index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
@@ -486,20 +491,51 @@ export default {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
})
const showLeaderApprovalPanel = computed(() =>
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& isDirectManagerApprovalStage.value
&& Boolean(request.value.claimId)
)
const isFinanceApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '财务审批'
})
const canReturnRequest = computed(() =>
canReturnExpenseClaims(currentUser.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
showLeaderApprovalPanel.value
&& canReturnExpenseClaims(currentUser.value)
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& (
(
isDirectManagerApprovalStage.value
&& canApproveLeaderExpenseClaims(currentUser.value)
)
|| (
isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
)
)
)
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() =>
isFinanceApprovalStage.value
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
)
const approvalOpinionHint = computed(() =>
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
)
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() =>
isFinanceApprovalStage.value
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
)
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
const approvalSuccessToast = computed(() =>
isFinanceApprovalStage.value
? `${request.value.id} 已完成财务终审,进入归档入账。`
: `${request.value.id} 已审批通过,流转至财务审批。`
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
@@ -564,7 +600,7 @@ export default {
},
{
key: 'date',
label: '日期',
label: '单据申请日期',
value: request.value.applyTime || request.value.occurredDisplay,
icon: 'mdi mdi-calendar-month-outline',
valueClass: ''
@@ -1011,12 +1047,23 @@ export default {
try {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
expenseAttachmentMeta[item.id] = payload?.attachment || null
applyLocalExpenseItemPatch(item.id, {
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
}
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)
}
}
emit('request-updated', { claimId: request.value.claimId })
@@ -1322,7 +1369,7 @@ export default {
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
toast('当前节点不支持审批通过。')
return
}
@@ -1345,7 +1392,7 @@ export default {
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
toast('当前节点不支持审批通过。')
approveConfirmDialogOpen.value = false
return
}
@@ -1357,7 +1404,7 @@ export default {
})
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
toast(approvalSuccessToast.value)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
@@ -1396,6 +1443,12 @@ export default {
attachmentPreviewUrl,
approveBusy,
approveConfirmDialogOpen,
approvalConfirmBadge,
approvalConfirmDescription,
approvalNextStage,
approvalOpinionHint,
approvalOpinionPlaceholder,
approvalOpinionTitle,
canDeleteRequest,
canManageCurrentClaim,
canNavigateAttachmentPreview,