feat(web): 优化差旅详情、风险建议卡片与文档中心交互

- 拆分阶段风险建议卡片样式到独立文件
- 完善差旅申请审批对话框与详情视图交互
- 调整文档中心列表共享样式与状态筛选
- 同步应用外壳、视图初始化与系统状态 composables
This commit is contained in:
caoxiaozhu
2026-06-17 14:39:12 +08:00
parent a3e5295915
commit 0fac8b615f
19 changed files with 1415 additions and 558 deletions

View File

@@ -41,7 +41,7 @@
v-if="openFilterKey === 'status'"
class="document-filter-menu status-filter-menu"
role="listbox"
aria-label="单据状态"
aria-label="风险等级"
>
<button
v-for="option in statusFilterOptions"
@@ -187,7 +187,7 @@
<col class="col-title">
<col class="col-amount">
<col class="col-node">
<col class="col-status">
<col class="col-risk">
<col class="col-updated">
</colgroup>
<thead>
@@ -201,7 +201,7 @@
<th>事项</th>
<th>金额</th>
<th>当前环节</th>
<th>状态</th>
<th>风险等级</th>
<th>更新时间</th>
</tr>
</thead>
@@ -219,7 +219,18 @@
<td data-label="事项">{{ row.reason }}</td>
<td data-label="金额">{{ row.amountDisplay }}</td>
<td data-label="当前环节">{{ row.node }}</td>
<td data-label="状态"><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td data-label="风险等级">
<span class="risk-level-tags">
<span
v-for="tag in row.riskTags"
:key="tag.label"
class="risk-level-tag"
:class="tag.tone"
>
{{ tag.label }}
</span>
</span>
</td>
<td data-label="更新时间">{{ row.updatedAtDisplay }}</td>
</tr>
</tbody>
@@ -253,6 +264,7 @@ import {
fetchAllApprovalExpenseClaims,
fetchAllArchivedExpenseClaims
} from '../services/reimbursements.js'
import { countClaimRisks, resolveArchiveRiskTone } from '../utils/archiveCenterListFilters.js'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
import {
buildDocumentViewedStatePatch,
@@ -292,46 +304,52 @@ const DOCUMENT_CENTER_QUERY_KEYS = new Set([
'dc_start',
'dc_end'
])
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '待付款', '已完成']
const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
const RISK_TONE_META = {
high: { label: '高风险', tone: 'high' },
medium: { label: '中风险', tone: 'medium' },
low: { label: '低风险', tone: 'low' },
none: { label: '无风险', tone: 'none' }
}
const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: {
searchPlaceholder: '搜索单号、事项、费用场景...',
sceneFallbackLabel: '单据场景',
dateLabel: '单据时间',
statusTitle: '单据状态',
statusTabs,
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: true
},
[DOCUMENT_SCOPE_APPLICATION]: {
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
sceneFallbackLabel: '申请场景',
dateLabel: '申请时间',
statusTitle: '申请状态',
statusTabs: ['全部', '草稿', '审批中', '已完成'],
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_REIMBURSEMENT]: {
searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
sceneFallbackLabel: '费用场景',
dateLabel: '报销时间',
statusTitle: '报销状态',
statusTabs,
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_REVIEW]: {
searchPlaceholder: '搜索审核单号、事项、当前环节...',
sceneFallbackLabel: '审核场景',
dateLabel: '审核时间',
statusTitle: '审核状态',
statusTabs: ['全部', '审批中', '待补充', '已完成'],
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_ARCHIVE]: {
searchPlaceholder: '搜索归档单号、事项、费用场景...',
sceneFallbackLabel: '归档场景',
dateLabel: '归档时间',
statusTitle: '归档状态',
statusTabs: ['全部', '已付款', '已完成'],
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: false
}
}
@@ -458,7 +476,7 @@ const documentTypeFilterLabel = computed(() =>
const statusFilterOptions = computed(() =>
activeFilterConfig.value.statusTabs.map((tab) => ({
value: tab,
label: tab === '全部' ? '全部状态' : tab
label: tab === '全部' ? '全部风险' : tab
}))
)
@@ -546,7 +564,7 @@ const sceneFilterLabel = computed(() =>
)
const statusFilterLabel = computed(() =>
statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部状态'
statusFilterOptions.value.find((item) => item.value === activeStatusTab.value)?.label || '全部风险'
)
const filteredRows = computed(() => {
@@ -560,7 +578,8 @@ const filteredRows = computed(() => {
row.initiatorName,
row.reason,
row.node,
row.statusLabel
row.statusLabel,
row.riskLabel
].filter(Boolean).join('').toLowerCase().includes(keyword)
const matchesDocumentType =
@@ -569,10 +588,10 @@ const filteredRows = computed(() => {
|| row.documentTypeCode === activeDocumentType.value
const matchesScene = activeScene.value === SCENE_ALL || row.typeCode === activeScene.value
const matchesStatus = matchesStatusTab(row, activeStatusTab.value)
const matchesRiskLevel = matchesRiskLevelTab(row, activeStatusTab.value)
const matchesDateRange = matchesAppliedDateRange(row)
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange
return matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange
}))
})
@@ -674,6 +693,7 @@ function buildDocumentRow(request, options = {}) {
const archived = Boolean(options.archived)
const statusGroup = resolveStatusGroup(normalized, archived)
const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
const riskMeta = buildDocumentRiskMeta(normalized)
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
@@ -708,6 +728,10 @@ function buildDocumentRow(request, options = {}) {
statusGroup,
statusLabel,
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
riskTone: riskMeta.tone,
riskLabel: riskMeta.label,
riskCount: riskMeta.count,
riskTags: riskMeta.tags,
source: options.source || 'owned',
archived,
createdAtDisplay: formatDocumentListTime(createdAtSource),
@@ -744,19 +768,48 @@ function resolveStatusTone(row, statusGroup) {
return row.approvalTone || 'neutral'
}
function matchesStatusTab(row, tab) {
function resolveDocumentRiskFlags(row) {
if (Array.isArray(row?.riskFlags)) {
return row.riskFlags
}
if (Array.isArray(row?.risk_flags_json)) {
return row.risk_flags_json
}
return []
}
function buildDocumentRiskMeta(row) {
const riskFlags = resolveDocumentRiskFlags(row)
const riskSummary = row?.riskSummary || row?.risk
const count = countClaimRisks(riskFlags, riskSummary)
if (!count) {
const meta = RISK_TONE_META.none
return {
...meta,
count: 0,
tags: [{ ...meta }]
}
}
const tone = resolveArchiveRiskTone(riskFlags, riskSummary)
const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium
return {
...meta,
count,
tags: [{ tone: meta.tone, label: `${meta.label} ${count}` }]
}
}
function matchesRiskLevelTab(row, tab) {
if (activeScopeTab.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
return false
}
if (tab === '全部') return true
if (tab === '草稿') return row.statusGroup === 'draft'
if (tab === '待提交') return row.statusGroup === 'pending_submit'
if (tab === '审批中') return row.statusGroup === 'in_progress'
if (tab === '待补充') return row.statusGroup === 'supplement'
if (tab === '待付款') return row.statusGroup === 'pending_payment'
if (tab === '已付款') return row.statusLabel === '已付款' || row.node === '已付款'
if (tab === '已完成') return row.statusGroup === 'completed'
if (tab === '高风险') return row.riskTone === 'high'
if (tab === '中风险') return row.riskTone === 'medium'
if (tab === '低风险') return row.riskTone === 'low'
if (tab === '无风险') return row.riskTone === 'none'
return true
}

View File

@@ -777,12 +777,16 @@
:title="`确认提交 ${request.id} 吗?`"
:description="submitConfirmDescription"
cancel-text="返回核对"
:secondary-text="submitConfirmSecondaryText"
secondary-tone="warning"
secondary-icon="mdi mdi-calculator-variant-outline"
:confirm-text="submitConfirmText"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-circle-outline"
:busy="submitBusy"
@close="closeSubmitConfirmDialog"
@secondary="confirmStandardAdjustment"
@confirm="confirmSubmitRequest"
>
<div class="submit-confirm-summary" aria-label="提交前核对摘要">
@@ -807,51 +811,55 @@
<ConfirmDialog
:open="riskOverrideDialogOpen"
badge="异常说明"
badge-tone="danger"
:badge-tone="riskOverrideBadgeTone"
size="review"
:title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`"
description="请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,可选择按职级最高可报销金额重新计算。"
cancel-text="返回整改"
confirm-text="按职级标准重算"
:title="riskOverrideDialogTitle"
:description="riskOverrideDialogDescription"
:cancel-text="riskOverrideCancelText"
:confirm-text="riskOverrideConfirmText"
busy-text="处理中..."
confirm-tone="danger"
confirm-icon="mdi mdi-calculator-variant-outline"
:confirm-tone="riskOverrideConfirmTone"
:confirm-icon="riskOverrideConfirmIcon"
:busy="riskOverrideBusy"
@close="closeRiskOverrideDialog"
@confirm="confirmStandardAdjustment"
@confirm="confirmRiskOverrideDialog"
>
<div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="异常说明">
<div class="risk-override-nav">
<div class="risk-override-card-shell">
<button
type="button"
class="risk-override-nav-btn"
:disabled="submitRiskWarnings.length <= 1 || riskOverrideBusy"
class="risk-override-side-nav risk-override-side-nav--previous"
:disabled="submitRiskReviewWarnings.length <= 1 || riskOverrideBusy"
aria-label="上一条风险"
@click="goToPreviousSubmitRisk"
>
<i class="mdi mdi-chevron-left"></i>
</button>
<span>{{ riskOverrideIndexLabel }}</span>
<article :class="['risk-override-card', currentSubmitRiskWarning.tone]">
<div class="risk-override-card-head">
<span>{{ currentSubmitRiskWarning.label }}</span>
<strong>{{ currentSubmitRiskWarning.title }}</strong>
</div>
<p>{{ currentSubmitRiskWarning.risk }}</p>
<div v-if="currentSubmitRiskWarningNotes.length" class="risk-override-notes">
<span>已填写异常说明</span>
<strong v-for="note in currentSubmitRiskWarningNotes" :key="note">{{ note }}</strong>
</div>
</article>
<button
type="button"
class="risk-override-nav-btn"
:disabled="submitRiskWarnings.length <= 1 || riskOverrideBusy"
class="risk-override-side-nav risk-override-side-nav--next"
:disabled="submitRiskReviewWarnings.length <= 1 || riskOverrideBusy"
aria-label="下一条风险"
@click="goToNextSubmitRisk"
>
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<article :class="['risk-override-card', currentSubmitRiskWarning.tone]">
<div class="risk-override-card-head">
<span>{{ currentSubmitRiskWarning.label }}</span>
<strong>{{ currentSubmitRiskWarning.title }}</strong>
</div>
<p>{{ currentSubmitRiskWarning.risk }}</p>
</article>
<div class="risk-override-index">{{ riskOverrideIndexLabel }}</div>
<div class="risk-override-guidance">
<strong>请在费用明细的异常说明列补充原因后再提交</strong>
<span>如果不补充说明可直接选择按职级标准重算超出标准的部分由员工自担</span>
<strong>{{ riskOverrideGuidanceTitle }}</strong>
<span>{{ riskOverrideGuidanceText }}</span>
</div>
</div>
</ConfirmDialog>
@@ -869,6 +877,9 @@
:opinion-placeholder="approvalOpinionPlaceholder"
:opinion-hint="approvalOpinionHint"
:opinion-required="requiresApprovalOpinion"
:risk-confirm-required="approvalRiskConfirmRequired"
v-model:risk-confirmed="approvalRiskConfirmed"
:risk-confirm-items="approvalRiskConfirmItems"
@close="closeApproveConfirmDialog"
@confirm="confirmApproveRequest"
/>

View File

@@ -31,7 +31,6 @@ import {
import {
canApproveBudgetExpenseApplications,
canApproveLeaderExpenseClaims,
canDeleteArchivedExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isCurrentDirectManagerForRequest,
@@ -97,7 +96,8 @@ import {
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel,
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel,
resolveExpenseItemsForRiskCard as resolveExpenseItemsForRiskCardModel
} from './travelRequestDetailStandardAdjustment.js'
import {
buildEmployeeProfileAdviceItems,
@@ -626,6 +626,7 @@ export default {
const returnDialogOpen = ref(false)
const approveBusy = ref(false)
const approveConfirmDialogOpen = ref(false)
const approvalRiskConfirmed = ref(false)
const leaderOpinion = ref('')
const expenseUploadInput = ref(null)
const smartEntryUploadInput = ref(null)
@@ -712,18 +713,7 @@ export default {
))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
}
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
if (canManageCurrentClaim.value) {
return true
}
return isEditableRequest.value && isCurrentApplicant.value
})
const canDeleteRequest = computed(() => isPlatformAdminUser(currentUser.value))
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
@@ -828,12 +818,30 @@ export default {
isApplicationDocument.value
&& hasLeaderApprovalEvents.value
))
const requiresApprovalOpinion = computed(() => false)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '附加意见'))
const budgetApprovalOpinionRequired = computed(() => (
isBudgetApprovalStage.value
&& hasBudgetApprovalWarning(request.value)
))
const requiresApprovalOpinion = computed(() => budgetApprovalOpinionRequired.value)
const approvalOpinionTitle = computed(() => {
if (isFinanceApprovalStage.value) {
return '财务意见'
}
if (isBudgetApprovalStage.value) {
return '预算审批意见'
}
return '附加意见'
})
const approvalOpinionPlaceholder = computed(() => {
if (isFinanceApprovalStage.value) {
return '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
}
if (budgetApprovalOpinionRequired.value) {
return '预算已超过警戒值,请写明预算审批意见、通过依据或后续控制要求。'
}
if (isBudgetApprovalStage.value) {
return '可选填预算审批补充说明;未超过预算警戒值时不填写默认为同意。'
}
if (isApplicationDocument.value) {
return '可选填审批补充说明,例如业务必要性、预算合理性或执行要求;不填写默认为同意。'
}
@@ -844,10 +852,35 @@ export default {
return '审核通过后将进入待付款。'
}
if (isBudgetApprovalStage.value) {
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
return budgetApprovalOpinionRequired.value
? '预算已超过警戒值,需填写预算审批意见后才能通过。'
: '未超过预算警戒值时不填写意见将默认同意,确认后按流程继续流转。'
}
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
})
const approvalRiskConfirmItems = computed(() =>
aiAdvice.value.riskCards
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.slice(0, 4)
.map((card, index) => ({
id: String(card?.id || `approval-risk-${index + 1}`),
tone: normalizeRiskTone(card?.tone),
label: normalizeRiskTone(card?.tone) === 'high' ? '高风险' : '中风险',
title: String(card?.title || card?.label || '风险提示').trim(),
description: String(
card?.relatedExplanationSummary
|| card?.risk
|| card?.summary
|| card?.suggestion
|| '请核对该风险点对应的说明和佐证材料。'
).trim()
}))
)
const approvalRiskConfirmRequired = computed(() =>
canApproveRequest.value
&& canViewApprovalRiskAdvice.value
&& approvalRiskConfirmItems.value.length > 0
)
const approvalConfirmBadge = computed(() => {
if (isFinanceApprovalStage.value) {
return '财务终审'
@@ -1183,6 +1216,10 @@ export default {
return resolveExpenseItemForRiskCardModel(card, expenseItems.value)
}
function resolveExpenseItemsForRiskCard(card) {
return resolveExpenseItemsForRiskCardModel(card, expenseItems.value)
}
function filterSubmitterResolvedRiskCards(cards, businessStage) {
const viewerContext = riskViewerContext.value || {}
return filterSubmitterResolvedRiskCardsModel({
@@ -1205,9 +1242,16 @@ export default {
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
}
function resolveRiskWarningNotes(card) {
const notes = resolveExpenseItemsForRiskCard(card)
.map((item) => String(item?.itemNote || '').trim())
.filter(Boolean)
return [...new Set(notes)]
}
async function buildStandardAdjustmentPayload() {
return buildStandardAdjustmentPayloadModel({
warnings: submitRiskWarnings.value,
warnings: submitRiskCards.value,
expenseItems: expenseItems.value,
request: request.value,
calculateTravelReimbursement
@@ -1733,24 +1777,72 @@ export default {
}))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value,
hasHighRiskWarnings: submitRiskWarnings.value.length > 0
hasHighRiskWarnings: aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
}))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
const submitRiskWarnings = computed(() =>
const submitRiskCards = computed(() =>
aiAdvice.value.riskCards
.filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.filter((card) => isRiskCardMissingExpenseNote(card))
.map((card, index) => ({
...card,
id: String(card.id || `submit-risk-${index}`),
tags: resolveRiskTags(card)
}))
)
const currentSubmitRiskWarning = computed(() => submitRiskWarnings.value[riskOverrideIndex.value] || null)
const riskOverrideIndexLabel = computed(() =>
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
const submitConfirmSecondaryText = computed(() => (
!isApplicationDocument.value && submitRiskCards.value.length
? '按职级标准报销'
: ''
))
const submitRiskWarnings = computed(() =>
submitRiskCards.value.filter((card) => isRiskCardMissingExpenseNote(card))
)
const submitExplainedRiskWarnings = computed(() =>
submitRiskCards.value.filter((card) => !isRiskCardMissingExpenseNote(card))
)
const hasMissingSubmitRiskWarnings = computed(() => submitRiskWarnings.value.length > 0)
const submitRiskReviewWarnings = computed(() =>
hasMissingSubmitRiskWarnings.value ? submitRiskWarnings.value : submitExplainedRiskWarnings.value
)
const currentSubmitRiskWarning = computed(() => submitRiskReviewWarnings.value[riskOverrideIndex.value] || null)
const currentSubmitRiskWarningNotes = computed(() =>
currentSubmitRiskWarning.value ? resolveRiskWarningNotes(currentSubmitRiskWarning.value) : []
)
const riskOverrideIndexLabel = computed(() =>
submitRiskReviewWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskReviewWarnings.value.length}` : ''
)
const riskOverrideBadgeTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'warning')
const riskOverrideDialogTitle = computed(() => (
hasMissingSubmitRiskWarnings.value
? `当前存在 ${submitRiskWarnings.value.length} 条需说明的风险`
: `请确认 ${submitExplainedRiskWarnings.value.length} 条风险及异常说明`
))
const riskOverrideDialogDescription = computed(() => (
hasMissingSubmitRiskWarnings.value
? '请回到费用明细的异常说明列补充原因后再提交;如果不补充说明,可选择按职级最高可报销金额重新计算。'
: '请核对风险点与已填写的异常说明,确认后进入提交确认。'
))
const riskOverrideCancelText = computed(() => (
hasMissingSubmitRiskWarnings.value ? '返回整改' : '返回核对'
))
const riskOverrideConfirmText = computed(() =>
hasMissingSubmitRiskWarnings.value ? '按职级标准重算' : '确认说明'
)
const riskOverrideConfirmTone = computed(() => hasMissingSubmitRiskWarnings.value ? 'danger' : 'primary')
const riskOverrideConfirmIcon = computed(() =>
hasMissingSubmitRiskWarnings.value ? 'mdi mdi-calculator-variant-outline' : 'mdi mdi-check-circle-outline'
)
const riskOverrideGuidanceTitle = computed(() => (
hasMissingSubmitRiskWarnings.value
? '请在费用明细的“异常说明”列补充原因后再提交。'
: '已填写异常说明,请确认说明会随单据进入审批。'
))
const riskOverrideGuidanceText = computed(() => (
hasMissingSubmitRiskWarnings.value
? '如果不补充说明,可直接选择按职级标准重算,超出标准的部分由员工自担。'
: '确认后系统会继续进入提交确认,领导和财务可看到这些风险及对应说明。'
))
function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value
@@ -1783,7 +1875,7 @@ export default {
}
function openRiskOverrideDialog() {
const warnings = submitRiskWarnings.value
const warnings = submitRiskReviewWarnings.value
if (!warnings.length) {
return
}
@@ -1799,18 +1891,34 @@ export default {
}
function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) {
if (!submitRiskReviewWarnings.value.length) {
return
}
riskOverrideIndex.value =
(riskOverrideIndex.value - 1 + submitRiskWarnings.value.length) % submitRiskWarnings.value.length
(riskOverrideIndex.value - 1 + submitRiskReviewWarnings.value.length) % submitRiskReviewWarnings.value.length
}
function goToNextSubmitRisk() {
if (!submitRiskWarnings.value.length) {
if (!submitRiskReviewWarnings.value.length) {
return
}
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskReviewWarnings.value.length
}
function confirmRiskExplanation() {
if (riskOverrideBusy.value || submitBusy.value) {
return
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
}
function confirmRiskOverrideDialog() {
if (hasMissingSubmitRiskWarnings.value) {
confirmStandardAdjustment()
return
}
confirmRiskExplanation()
}
function confirmStandardAdjustment() {
@@ -1824,6 +1932,7 @@ export default {
}
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = false
standardAdjustmentBusy.value = true
const taskSeq = ++standardAdjustmentTaskSeq
toast('\u6b63\u5728\u540e\u53f0\u6309\u804c\u7ea7\u6807\u51c6\u91cd\u65b0\u6d4b\u7b97\u8d39\u7528\u3002')
@@ -2308,7 +2417,7 @@ export default {
return
}
if (submitRiskWarnings.value.length) {
if (submitRiskReviewWarnings.value.length) {
openRiskOverrideDialog()
return
}
@@ -2490,6 +2599,7 @@ export default {
return
}
approvalRiskConfirmed.value = !approvalRiskConfirmRequired.value
approveConfirmDialogOpen.value = true
}
@@ -2522,6 +2632,16 @@ export default {
return
}
if (approvalRiskConfirmRequired.value && !approvalRiskConfirmed.value) {
toast('请先确认已核对风险说明和佐证材料,再继续审批。')
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('预算已超过警戒值,请填写预算审批意见后再通过。')
return
}
approveBusy.value = true
try {
const responsePayload = await approveExpenseClaim(request.value.claimId, {
@@ -2529,13 +2649,17 @@ export default {
})
const generatedDraftClaimNo = resolveGeneratedDraftClaimNo(responsePayload)
approveConfirmDialogOpen.value = false
approvalRiskConfirmed.value = false
leaderOpinion.value = ''
toast(
isApplicationDocument.value && generatedDraftClaimNo
? `${request.value.id} 已确认审核,报销草稿 ${generatedDraftClaimNo} 已生成。`
: approvalSuccessToast.value
)
emit('request-updated', { claimId: request.value.claimId })
emit('request-updated', {
claimId: request.value.claimId,
claim: responsePayload
})
emit('backToRequests')
} catch (error) {
toast(resolveApproveErrorMessage(error))
@@ -2636,6 +2760,7 @@ export default {
attachmentPreviewUrl, approveBusy, approveConfirmDialogOpen, approvalConfirmBadge,
approvalConfirmDescription, approvalOpinionHint,
approvalOpinionPlaceholder, approvalOpinionTitle, approveActionLabel, approveBusyLabel,
approvalRiskConfirmed, approvalRiskConfirmItems, approvalRiskConfirmRequired,
applicationDetailFactItems, relatedApplicationFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
@@ -2643,10 +2768,10 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmStandardAdjustment, confirmSmartEntryUpload,
confirmPayRequest, confirmRiskExplanation, confirmRiskOverrideDialog, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning,
currentSubmitRiskWarning, currentSubmitRiskWarningNotes,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor,
@@ -2668,7 +2793,10 @@ export default {
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBadgeTone, riskOverrideBusy,
riskOverrideCancelText, riskOverrideConfirmIcon, riskOverrideConfirmText, riskOverrideConfirmTone,
riskOverrideDialogDescription, riskOverrideDialogOpen, riskOverrideDialogTitle,
riskOverrideGuidanceText, riskOverrideGuidanceTitle, riskOverrideIndexLabel,
requiresApprovalOpinion,
saveDetailNote, savingDetailNote, savingExpenseId,
smartEntrySelectedFileCount, smartEntrySelectedFileNames, smartEntrySelectedFileSummary,
@@ -2678,8 +2806,56 @@ export default {
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmSecondaryText, submitConfirmText,
submitExplainedRiskWarnings, submitRiskReviewWarnings, submitRiskWarnings, hasMissingSubmitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}
}
}
function hasBudgetApprovalWarning(request = {}) {
const flags = Array.isArray(request?.riskFlags)
? request.riskFlags
: Array.isArray(request?.risk_flags_json)
? request.risk_flags_json
: []
return flags.some((flag) => {
if (!flag || typeof flag !== 'object') {
return false
}
const routeDecision = flag.route_decision || flag.routeDecision || {}
const directBudgetResult = flag.budget_result || flag.budgetResult
const routeBudgetResult = routeDecision?.budget_result || routeDecision?.budgetResult
const budgetResult = routeBudgetResult || directBudgetResult
if (!budgetResult || typeof budgetResult !== 'object') {
return false
}
return budgetResultExceedsWarning(budgetResult)
})
}
function budgetResultExceedsWarning(budgetResult = {}) {
const metrics = budgetResult.metrics && typeof budgetResult.metrics === 'object' ? budgetResult.metrics : {}
const context = budgetResult.budget_context && typeof budgetResult.budget_context === 'object'
? budgetResult.budget_context
: budgetResult.budgetContext && typeof budgetResult.budgetContext === 'object'
? budgetResult.budgetContext
: {}
const overBudgetAmount = parseBudgetNumber(metrics.over_budget_amount ?? metrics.overBudgetAmount)
if (overBudgetAmount > 0) {
return true
}
const afterUsageRate = parseBudgetNumber(metrics.after_usage_rate ?? metrics.afterUsageRate)
const claimAmountRatio = parseBudgetNumber(metrics.claim_amount_ratio ?? metrics.claimAmountRatio)
const warningThreshold = parseBudgetNumber(context.warning_threshold ?? context.warningThreshold, 80)
return Math.max(afterUsageRate, claimAmountRatio) >= warningThreshold
}
function parseBudgetNumber(value, fallback = 0) {
const number = Number(value)
return Number.isFinite(number) ? number : fallback
}

View File

@@ -107,6 +107,25 @@ export function isAttachmentRequiredExpenseItem(source) {
return !isSystemGeneratedExpenseItemSource({ ...source, itemType }) && !OPTIONAL_ATTACHMENT_EXPENSE_TYPES.has(itemType)
}
export function hasUploadedReceiptReference(source) {
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
if (!isPlaceholderValue(invoiceId)) {
return true
}
return Array.isArray(source?.attachments) && source.attachments.some((item) => !isPlaceholderValue(item))
}
export function isIgnorableExpenseDraftPlaceholder(item) {
if (!item || isSystemGeneratedExpenseItemSource(item) || hasUploadedReceiptReference(item)) {
return false
}
const amount = Number(item?.itemAmount ?? item?.item_amount ?? 0)
const missingAmount = !Number.isFinite(amount) || amount <= 0
const missingReason = isPlaceholderValue(item?.itemReason ?? item?.item_reason)
const missingLocation = isPlaceholderValue(item?.itemLocation ?? item?.item_location)
return missingAmount && missingReason && missingLocation
}
export function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
}
@@ -568,6 +587,7 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
export function rebuildExpenseItems(items, requestModel) {
const sortedItems = [...items]
.filter((item) => !isIgnorableExpenseDraftPlaceholder(item))
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
@@ -575,29 +595,33 @@ export function rebuildExpenseItems(items, requestModel) {
export function buildExpenseDraftIssues(item) {
const issues = []
if (item.isSystemGenerated) {
if (item.isSystemGenerated || isSystemGeneratedExpenseItemSource(item)) {
return issues
}
if (isIgnorableExpenseDraftPlaceholder(item)) {
return issues
}
const locationRequired = isLocationRequiredExpenseType(item.itemType)
const hasUploadedReceipt = hasUploadedReceiptReference(item)
if (!isValidIsoDate(item.itemDate)) {
if (!hasUploadedReceipt && !isValidIsoDate(item.itemDate)) {
issues.push('缺少日期')
}
if (isPlaceholderValue(item.itemType)) {
issues.push('缺少费用项目')
}
if (isPlaceholderValue(item.itemReason)) {
if (!hasUploadedReceipt && isPlaceholderValue(item.itemReason)) {
issues.push('缺少说明')
} else if (isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
} else if (!hasUploadedReceipt && isRouteDescriptionExpenseType(item.itemType) && !isValidRouteDescription(item.itemReason)) {
issues.push('行程说明格式错误')
}
if (locationRequired && isPlaceholderValue(item.itemLocation)) {
if (!hasUploadedReceipt && locationRequired && isPlaceholderValue(item.itemLocation)) {
issues.push('缺少地点')
}
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
if (!hasUploadedReceipt && (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0)) {
issues.push('缺少金额')
}
if (isAttachmentRequiredExpenseItem(item) && isPlaceholderValue(item.invoiceId)) {
if (isAttachmentRequiredExpenseItem(item) && !hasUploadedReceipt) {
issues.push('缺少票据标识')
}
@@ -609,14 +633,15 @@ export function buildDraftBlockingIssues(request, expenseItems) {
const isApplication = isApplicationDocumentRequest(request)
const locationRequired = isLocationRequiredExpenseType(request.typeCode)
const normalizedItems = Array.isArray(expenseItems) ? expenseItems : []
const itemAmountTotal = normalizedItems.reduce((sum, item) => {
const effectiveItems = normalizedItems.filter((item) => !isIgnorableExpenseDraftPlaceholder(item))
const itemAmountTotal = effectiveItems.reduce((sum, item) => {
const amount = Number(item?.itemAmount || 0)
return Number.isFinite(amount) && amount > 0 ? sum + amount : sum
}, 0)
const hasValidItemDate = normalizedItems.some((item) => isValidIsoDate(item?.itemDate))
const hasValidItemType = normalizedItems.some((item) => !isPlaceholderValue(item?.itemType))
const hasValidItemReason = normalizedItems.some((item) => !isPlaceholderValue(item?.itemReason))
const hasValidItemLocation = normalizedItems.some((item) => !isPlaceholderValue(item?.itemLocation))
const hasValidItemDate = effectiveItems.some((item) => isValidIsoDate(item?.itemDate))
const hasValidItemType = effectiveItems.some((item) => !isPlaceholderValue(item?.itemType))
const hasValidItemReason = effectiveItems.some((item) => !isPlaceholderValue(item?.itemReason))
const hasValidItemLocation = effectiveItems.some((item) => !isPlaceholderValue(item?.itemLocation))
if (isPlaceholderValue(request.profileName)) {
issues.push('申请人未完善')
@@ -655,7 +680,7 @@ export function buildDraftBlockingIssues(request, expenseItems) {
if ((!Number.isFinite(Number(request.amountValue)) || Number(request.amountValue) <= 0) && itemAmountTotal <= 0) {
issues.push('报销金额未完善')
}
if (!normalizedItems.length) {
if (!effectiveItems.length) {
issues.push('费用明细不能为空')
}

View File

@@ -66,6 +66,32 @@ function cardLikeText(card = {}) {
].map((item) => normalizeText(item)).join(' ')
}
function resolveRiskCardItemIds(card = {}) {
return normalizeIdList([
card.itemId,
card.item_id,
...(Array.isArray(card.itemIds) ? card.itemIds : []),
...(Array.isArray(card.item_ids) ? card.item_ids : [])
])
}
function resolveDuplicateRiskGroup(card = {}) {
const text = cardLikeText(card)
if (/多城市行程|中转|多地拜访|改签|多地出差|后续行程|行程终点异常|连续闭环/.test(text) && /待说明|未说明|缺少说明|原因|说明|不一致|异常/.test(text)) {
return 'route-explanation'
}
return ''
}
function riskCardsReferToSameIssue(left = {}, right = {}) {
const leftItemIds = resolveRiskCardItemIds(left)
const rightItemIds = resolveRiskCardItemIds(right)
if (!leftItemIds.length || !rightItemIds.length) {
return true
}
return leftItemIds.some((itemId) => rightItemIds.includes(itemId))
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
@@ -95,6 +121,30 @@ function isRiskTone(tone) {
return ['medium', 'high'].includes(normalizeText(tone).toLowerCase())
}
function riskToneWeight(tone) {
const normalizedTone = normalizeTone(tone)
if (normalizedTone === 'high') return 0
if (normalizedTone === 'medium') return 1
if (normalizedTone === 'low') return 2
if (normalizedTone === 'pass') return 4
return 9
}
function dedupeLowerSeverityRiskCards(cards = []) {
return cards.filter((card, index) => {
const duplicateGroup = resolveDuplicateRiskGroup(card)
if (!duplicateGroup) {
return true
}
return !cards.some((otherCard, otherIndex) => (
otherIndex !== index
&& resolveDuplicateRiskGroup(otherCard) === duplicateGroup
&& riskToneWeight(otherCard?.tone || otherCard?.severity) < riskToneWeight(card?.tone || card?.severity)
&& riskCardsReferToSameIssue(card, otherCard)
))
})
}
function normalizeId(value) {
return normalizeText(value)
}
@@ -108,6 +158,152 @@ function normalizeIdList(value) {
return [...new Set(rawValues.map((item) => normalizeId(item)).filter(Boolean))]
}
function normalizeExpenseItemNote(item = {}) {
const note = normalizeText(item.itemNote ?? item.item_note).replace(/[。;;]+$/, '')
if (!note || ['待补充', '待补充异常说明', '暂无', '无', 'null', 'undefined'].includes(note)) {
return ''
}
return note
}
function resolveExpenseItemLabel(item = {}, fallback = '相关明细') {
return normalizeText(item.desc)
|| normalizeText(item.itemReason)
|| normalizeText(item.detail)
|| normalizeText(item.name)
|| normalizeText(item.category)
|| fallback
}
function resolveRelatedExpenseExplanations(itemIds = [], expenseItems = []) {
const relatedIds = normalizeIdList(itemIds)
if (!relatedIds.length || !Array.isArray(expenseItems)) {
return []
}
return expenseItems
.filter((item) => relatedIds.includes(normalizeId(item?.id)))
.map((item) => {
const note = normalizeExpenseItemNote(item)
if (!note) {
return null
}
return {
itemId: normalizeId(item.id),
label: resolveExpenseItemLabel(item),
note,
text: `${resolveExpenseItemLabel(item)}${note}`
}
})
.filter(Boolean)
}
function isHotelExpenseItem(item = {}) {
const text = [
item.name,
item.category,
item.desc,
item.detail,
item.itemType,
item.item_type,
item.documentType,
item.document_type
].map((value) => normalizeText(value)).join(' ')
return /住宿|酒店|宾馆|hotel/.test(text)
}
function isTrafficExpenseItem(item = {}) {
const text = [
item.name,
item.category,
item.desc,
item.detail,
item.itemType,
item.item_type,
item.documentType,
item.document_type
].map((value) => normalizeText(value)).join(' ')
if (/补贴|系统自动计算/.test(text)) {
return false
}
return /交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi/.test(text)
}
function inferRelatedExpenseItemIdsByRiskText(flag = {}, risks = [], expenseItems = []) {
const text = [
cardLikeText(flag),
...uniqueTexts(risks)
].map((value) => normalizeText(value)).join(' ')
const items = Array.isArray(expenseItems) ? expenseItems : []
if (isHotelOverStandardRiskText(text) || /住宿|酒店|宾馆|hotel/.test(text)) {
return items.filter(isHotelExpenseItem).map((item) => normalizeId(item?.id)).filter(Boolean)
}
if (/交通|火车|高铁|机票|航班|出租车|网约车|乘车|车票|train|flight|taxi/.test(text)) {
return items.filter(isTrafficExpenseItem).map((item) => normalizeId(item?.id)).filter(Boolean)
}
return []
}
function resolveRelatedItemIdsForRisk({
explicitItemIds = [],
flag = {},
risks = [],
expenseItems = []
} = {}) {
const normalizedExplicitItemIds = normalizeIdList(explicitItemIds)
const routeRelatedItemIds = resolveRouteRelatedItemIdsForRisk({
flagItemIds: normalizedExplicitItemIds,
flag,
risks,
expenseItems
})
if (routeRelatedItemIds.length) {
return routeRelatedItemIds
}
if (normalizedExplicitItemIds.length) {
return normalizedExplicitItemIds
}
return inferRelatedExpenseItemIdsByRiskText(flag, risks, expenseItems)
}
function formatRelatedExpenseExplanations(explanations = [], limit = 3) {
const texts = uniqueTexts(explanations.map((item) => item.text))
if (!texts.length) {
return ''
}
const visible = texts.slice(0, limit).join('')
return `${visible}${texts.length > limit ? `${texts.length}` : ''}`
}
function resolveRiskTextWithExplanations(risk, explanations = []) {
const text = normalizeText(risk)
if (!text || !explanations.length) {
return text
}
if (/未说明|待说明|缺少说明|未识别到.*说明|请.*补充/.test(text)) {
const cleaned = text
.replace(/?但当前[^。;;]*?(?:未说明|待说明|缺少说明|未识别到[^。;;]*说明)[^。;;]*[。;;]?/g, '')
.replace(/当前[^。;;]*?(?:未说明|待说明|缺少说明|未识别到[^。;;]*说明)[^。;;]*[。;;]?/g, '')
.replace(/请[^。;;]*?(?:补充|写清楚)[^。;;]*[。;;]?/g, '')
.trim()
const base = cleaned.replace(/[,;。]+$/, '') || '该风险已命中系统规则'
return `${base},用户已在相关费用明细补充异常说明,需审核说明是否充分。`
}
return text
}
function resolveClaimRiskSuggestion(flag = {}, { risk = '', summary = '', relatedExplanations = [] } = {}) {
const explanationSummary = formatRelatedExpenseExplanations(relatedExplanations)
if (explanationSummary) {
return `用户已在费用明细补充异常说明:${explanationSummary}。请审核说明是否充分,并结合票据、行程和制度标准决定通过、退回或要求补充佐证。`
}
return resolveClaimRiskFallbackSuggestion(flag, { risk, summary })
}
function resolveItemRiskFlag(item, claimRiskFlags) {
const itemId = normalizeId(item?.id)
if (!itemId || !Array.isArray(claimRiskFlags)) {
@@ -276,7 +472,7 @@ function resolveClaimRiskRuleBasis(flag = {}, { risk = '', summary = '', tone =
return uniqueTexts(basis.length ? basis : [`系统预审根据“${label || '单据风险'}”将该项列为${tone === 'high' ? '高风险' : '中风险'}`])
}
function resolveClaimRiskSuggestion(flag = {}, { risk = '', summary = '' } = {}) {
function resolveClaimRiskFallbackSuggestion(flag = {}, { risk = '', summary = '' } = {}) {
const explicitSuggestion = normalizeText(flag.suggestion)
if (explicitSuggestion) {
return explicitSuggestion
@@ -370,6 +566,12 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name),
'附件风险'
)
const relatedExplanations = resolveRelatedExpenseExplanations([normalizeId(item?.id)], [item])
const explanationSummary = formatRelatedExpenseExplanations(relatedExplanations)
const ruleBasis = uniqueTexts([
...(insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。']),
explanationSummary ? `用户已补充异常说明:${explanationSummary}` : ''
])
return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
@@ -380,13 +582,24 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
tone,
label: resolveRiskLevelLabel(tone),
title,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
risk: resolveRiskTextWithExplanations(
normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
relatedExplanations
),
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight),
ruleBasis,
suggestion: relatedExplanations.length
? resolveClaimRiskSuggestion({}, {
risk: normalizeText(point) || normalizeText(analysis?.summary),
summary: normalizeText(analysis?.summary),
relatedExplanations
})
: buildCardSuggestion(analysis, insight),
source: 'attachment_analysis',
itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel),
relatedExplanations,
relatedExplanationSummary: explanationSummary,
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
})
@@ -599,8 +812,8 @@ export function buildAttachmentRiskCards({
const risks = flagPoints.length
? flagPoints
: [primaryRisk || fallbackRisk].filter(Boolean)
const relatedItemIds = resolveRouteRelatedItemIdsForRisk({
flagItemIds,
const relatedItemIds = resolveRelatedItemIdsForRisk({
explicitItemIds: [flagItemId, ...flagItemIds],
flag,
risks,
expenseItems
@@ -616,6 +829,12 @@ export function buildAttachmentRiskCards({
summary,
tone
})
const relatedExplanations = resolveRelatedExpenseExplanations(relatedItemIds, expenseItems)
const explanationSummary = formatRelatedExpenseExplanations(relatedExplanations)
const mergedRuleBasis = uniqueTexts([
...ruleBasis,
explanationSummary ? `用户已补充异常说明:${explanationSummary}` : ''
])
return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`,
@@ -627,10 +846,12 @@ export function buildAttachmentRiskCards({
tone,
label: resolveRiskLevelLabel(tone),
title,
risk,
risk: resolveRiskTextWithExplanations(risk, relatedExplanations),
summary,
ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
ruleBasis: mergedRuleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary, relatedExplanations }),
relatedExplanations,
relatedExplanationSummary: explanationSummary,
source,
risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope,
@@ -702,7 +923,7 @@ export function buildAiAdviceViewModel({
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean)
const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)
const normalizedRiskCards = dedupeLowerSeverityRiskCards(riskCards.filter(Boolean))
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)

View File

@@ -30,22 +30,59 @@ export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = [])
}
export function resolveExpenseItemForRiskCard(card, expenseItems = []) {
const itemId = normalizeText(card?.itemId || card?.item_id)
const itemIds = [
card?.itemId,
card?.item_id,
...(Array.isArray(card?.itemIds) ? card.itemIds : []),
...(Array.isArray(card?.item_ids) ? card.item_ids : [])
].map(normalizeText).filter(Boolean)
const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id)
const itemIndex = Number(card?.itemIndex || card?.item_index || 0)
return expenseItems.find((item) => normalizeText(item.id) === itemId)
return expenseItems.find((item) => itemIds.includes(normalizeText(item.id)))
|| expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId)
|| (itemIndex > 0 ? expenseItems[itemIndex - 1] : null)
|| null
}
export function resolveExpenseItemsForRiskCard(card, expenseItems = []) {
const itemIds = [
card?.itemId,
card?.item_id,
...(Array.isArray(card?.itemIds) ? card.itemIds : []),
...(Array.isArray(card?.item_ids) ? card.item_ids : [])
].map(normalizeText).filter(Boolean)
const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id)
const itemIndex = Number(card?.itemIndex || card?.item_index || 0)
const matchedItems = []
const appendItem = (item) => {
if (!item || matchedItems.some((entry) => normalizeText(entry.id) === normalizeText(item.id))) {
return
}
matchedItems.push(item)
}
if (itemIds.length) {
expenseItems
.filter((item) => itemIds.includes(normalizeText(item.id)))
.forEach(appendItem)
}
if (matchedItems.length) {
return matchedItems
}
appendItem(expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId))
appendItem(itemIndex > 0 ? expenseItems[itemIndex - 1] : null)
return matchedItems
}
export function isRiskCardMissingExpenseNote(card, expenseItems = []) {
const item = resolveExpenseItemForRiskCard(card, expenseItems)
if (!item) {
const items = resolveExpenseItemsForRiskCard(card, expenseItems)
if (!items.length) {
return true
}
return !normalizeText(item.itemNote)
return items.some((item) => !normalizeText(item.itemNote))
}
export function hasStandardAdjustmentForItem(item, standardAdjustmentMap = new Map()) {