feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user