feat(web): 优化差旅详情、风险建议卡片与文档中心交互
- 拆分阶段风险建议卡片样式到独立文件 - 完善差旅申请审批对话框与详情视图交互 - 调整文档中心列表共享样式与状态筛选 - 同步应用外壳、视图初始化与系统状态 composables
This commit is contained in:
@@ -507,7 +507,8 @@ td small {
|
||||
|
||||
.doc-kind-tag,
|
||||
.type-tag,
|
||||
.status-tag {
|
||||
.status-tag,
|
||||
.risk-level-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -612,6 +613,49 @@ td small {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.risk-level-tags {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.risk-level-tag {
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-level-tag.high {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.risk-level-tag.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.risk-level-tag.low {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.risk-level-tag.none {
|
||||
border-color: #e2e8f0;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.list-foot {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
|
||||
302
web/src/assets/styles/components/stage-risk-advice-card.css
Normal file
302
web/src/assets/styles/components/stage-risk-advice-card.css
Normal file
@@ -0,0 +1,302 @@
|
||||
.employee-risk-profile-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.employee-risk-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.employee-risk-title-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.detail-card-head h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-card-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-card-title-with-icon i {
|
||||
margin-top: 1px;
|
||||
color: #334155;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill {
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.normal {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.high {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.employee-risk-decision-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 32%);
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.employee-risk-decision-panel.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.employee-risk-decision-panel.high {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.employee-risk-decision-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.employee-risk-decision-main > span,
|
||||
.employee-risk-decision-action span {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-decision-main strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.employee-risk-decision-panel.medium .employee-risk-decision-main strong {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-decision-panel.high .employee-risk-decision-main strong {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-decision-main p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-decision-action {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.employee-risk-decision-action strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.employee-risk-decision-action strong.medium {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-decision-action strong.high {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-profile-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.employee-risk-section-head {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.employee-risk-section-head span {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-section-head small {
|
||||
min-width: 0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.employee-risk-profile-list {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-row.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fffbf5;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-row.high {
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-title {
|
||||
min-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
color: #0f172a;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-title span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-title strong {
|
||||
height: 20px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-row.medium .employee-risk-evidence-title strong {
|
||||
background: #ffedd5;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-row.high .employee-risk-evidence-title strong {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-row ul {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-row li {
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.employee-risk-muted {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.employee-risk-decision-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.employee-risk-title-wrap,
|
||||
.employee-risk-section-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.employee-risk-section-head small {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
.col-title { width: 16%; }
|
||||
.col-amount { width: 9%; }
|
||||
.col-node { width: 12%; }
|
||||
.col-status { width: 8%; }
|
||||
.col-risk { width: 8%; }
|
||||
.col-updated { width: 9%; }
|
||||
|
||||
.new-document-badge {
|
||||
|
||||
@@ -2059,36 +2059,45 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.risk-override-nav {
|
||||
.risk-override-card-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 34px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 28px;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.risk-override-nav span {
|
||||
.risk-override-side-nav {
|
||||
width: 28px;
|
||||
min-height: 76px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: stretch;
|
||||
border: 1px solid #dbe3ee;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.risk-override-side-nav:not(:disabled):hover {
|
||||
border-color: #b8c5d6;
|
||||
background: #eef4fb;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.risk-override-side-nav:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .48;
|
||||
}
|
||||
|
||||
.risk-override-index {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-override-nav-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.risk-override-nav-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: .48;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.risk-override-card {
|
||||
@@ -2135,6 +2144,26 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.risk-override-notes {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed #cbd5e1;
|
||||
}
|
||||
|
||||
.risk-override-notes span {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.risk-override-notes strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 780;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.risk-override-guidance {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@@ -34,11 +34,22 @@
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
v-if="secondaryText"
|
||||
type="button"
|
||||
class="shared-confirm-btn secondary"
|
||||
:class="secondaryTone"
|
||||
:disabled="busy"
|
||||
@click="$emit('secondary')"
|
||||
>
|
||||
<i v-if="secondaryIcon" :class="secondaryIcon"></i>
|
||||
<span>{{ secondaryText }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shared-confirm-btn confirm"
|
||||
:class="confirmTone"
|
||||
:disabled="busy"
|
||||
:disabled="busy || confirmDisabled"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
<i v-if="confirmIcon" :class="busy ? 'mdi mdi-loading mdi-spin' : confirmIcon"></i>
|
||||
@@ -61,17 +72,21 @@ const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
cancelText: { type: String, default: '取消' },
|
||||
secondaryText: { type: String, default: '' },
|
||||
secondaryTone: { type: String, default: 'warning' },
|
||||
secondaryIcon: { type: String, default: '' },
|
||||
confirmText: { type: String, default: '确认' },
|
||||
busyText: { type: String, default: '处理中...' },
|
||||
confirmTone: { type: String, default: 'primary' },
|
||||
confirmIcon: { type: String, default: '' },
|
||||
confirmDisabled: { type: Boolean, default: false },
|
||||
busy: { type: Boolean, default: false },
|
||||
closeOnMask: { type: Boolean, default: true },
|
||||
size: { type: String, default: 'default' },
|
||||
actionsAlign: { type: String, default: 'end' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'cancel', 'confirm'])
|
||||
const emit = defineEmits(['close', 'cancel', 'secondary', 'confirm'])
|
||||
const instance = getCurrentInstance()
|
||||
|
||||
const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`)
|
||||
@@ -219,6 +234,24 @@ function handleCancel() {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.secondary {
|
||||
border: 1px solid rgba(245, 158, 11, 0.28);
|
||||
background: rgba(255, 251, 235, 0.92);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.shared-confirm-btn.secondary.primary {
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28);
|
||||
background: var(--theme-primary-soft);
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.shared-confirm-btn.secondary.danger {
|
||||
border-color: rgba(var(--danger-rgb), 0.24);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger-hover);
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm {
|
||||
border: 1px solid transparent;
|
||||
color: #fff;
|
||||
@@ -239,6 +272,11 @@ function handleCancel() {
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.shared-confirm-btn.secondary:hover:not(:disabled) {
|
||||
border-color: rgba(245, 158, 11, 0.42);
|
||||
background: rgba(254, 243, 199, 0.96);
|
||||
}
|
||||
|
||||
.shared-confirm-btn.confirm:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="detail-card-head employee-risk-head">
|
||||
<div class="employee-risk-title-wrap">
|
||||
<h3 class="detail-card-title-with-icon">
|
||||
<i class="mdi mdi-account-search-outline"></i>
|
||||
<i class="mdi mdi-file-document-alert-outline"></i>
|
||||
<span>{{ stageTitle }}</span>
|
||||
</h3>
|
||||
<span :class="['employee-risk-tone-pill', decisionTone]">{{ decisionBadgeLabel }}</span>
|
||||
@@ -11,45 +11,39 @@
|
||||
</div>
|
||||
|
||||
<div class="employee-risk-body">
|
||||
<section :class="['employee-risk-ai-note', decisionTone]">
|
||||
<div class="employee-risk-ai-main">
|
||||
<span>AI 审核建议</span>
|
||||
<section :class="['employee-risk-decision-panel', decisionTone]">
|
||||
<div class="employee-risk-decision-main">
|
||||
<span>综合审核结论</span>
|
||||
<strong>{{ decisionTitle }}</strong>
|
||||
<p>{{ decisionDescription }}</p>
|
||||
</div>
|
||||
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
|
||||
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
|
||||
</div>
|
||||
<div class="employee-risk-action">
|
||||
<span>建议动作</span>
|
||||
<div class="employee-risk-decision-action">
|
||||
<span>建议结论</span>
|
||||
<strong :class="decisionTone">{{ decisionAction }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="employee-risk-profile-section">
|
||||
<section class="employee-risk-profile-section" aria-label="单据风险依据">
|
||||
<div class="employee-risk-section-head">
|
||||
<span>{{ stageBasisTitle }}</span>
|
||||
<small>{{ stageBasisHint }}</small>
|
||||
</div>
|
||||
|
||||
<div class="employee-risk-profile-list">
|
||||
<section
|
||||
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
|
||||
<article
|
||||
v-for="item in compactEvidenceItems"
|
||||
:key="item.code"
|
||||
:class="['employee-risk-profile', item.tone]"
|
||||
:class="['employee-risk-evidence-row', item.tone]"
|
||||
>
|
||||
<div class="employee-risk-profile-title">
|
||||
<div class="employee-risk-evidence-title">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong :class="item.tone">{{ item.status }}</strong>
|
||||
<strong>{{ item.status }}</strong>
|
||||
</div>
|
||||
<ul v-if="item.evidence.length" class="employee-risk-evidence-list">
|
||||
<li v-for="basis in item.evidence" :key="basis">
|
||||
{{ basis }}
|
||||
</li>
|
||||
<ul v-if="item.evidence.length">
|
||||
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
<p v-else class="employee-risk-muted">暂无显著贡献项。</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据。</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
@@ -81,6 +75,7 @@ export default {
|
||||
setup(props) {
|
||||
const requestModel = computed(() => props.request || {})
|
||||
const currentItems = computed(() => Array.isArray(props.expenseItems) ? props.expenseItems : [])
|
||||
// 只消费语义层归一后的风险卡片,展示层不新增业务风险字段。
|
||||
const currentRiskCards = computed(() =>
|
||||
(Array.isArray(props.aiAdvice?.riskCards) ? props.aiAdvice.riskCards : [])
|
||||
.filter((card) => matchesCurrentStage(card, props.isApplicationDocument))
|
||||
@@ -88,6 +83,7 @@ export default {
|
||||
)
|
||||
const highRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'high'))
|
||||
const mediumRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'medium'))
|
||||
const riskExplanationItems = computed(() => uniqueTexts(currentRiskCards.value.flatMap((card) => riskExplanationTexts([card]))))
|
||||
const materialIssues = computed(() => props.isApplicationDocument ? [] : resolveReimbursementMaterialIssues(currentItems.value))
|
||||
const sceneIssues = computed(() => resolveSceneIssues(requestModel.value, currentItems.value, props.isApplicationDocument))
|
||||
const decisionTone = computed(() => {
|
||||
@@ -100,14 +96,19 @@ export default {
|
||||
return 'normal'
|
||||
})
|
||||
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
|
||||
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请环节风险依据' : '报销环节风险依据')
|
||||
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单风险依据' : '报销单风险依据')
|
||||
const stageBasisHint = computed(() => (
|
||||
props.isApplicationDocument
|
||||
? '只展示本次申请可能影响预算和审批的风险。'
|
||||
: '只展示本次报销可能影响票据、金额和付款的风险。'
|
||||
? '仅展示申请单本身的金额、预算触发、事由和规则命中依据。'
|
||||
: '仅展示报销单本身的票据、金额、行程和规则命中依据。'
|
||||
))
|
||||
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
|
||||
const decisionAction = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).action)
|
||||
const decisionAction = computed(() => {
|
||||
if (!props.isApplicationDocument && riskExplanationItems.value.length && ['medium', 'high'].includes(decisionTone.value)) {
|
||||
return '请核对已补充说明是否覆盖风险点,再决定通过或退回补充。'
|
||||
}
|
||||
return resolveDecision(decisionTone.value, props.isApplicationDocument).action
|
||||
})
|
||||
const decisionBadgeLabel = computed(() => {
|
||||
if (decisionTone.value === 'high') {
|
||||
return '高风险'
|
||||
@@ -120,6 +121,9 @@ export default {
|
||||
const decisionDescription = computed(() => {
|
||||
const riskCount = currentRiskCards.value.length
|
||||
if (riskCount) {
|
||||
if (!props.isApplicationDocument && riskExplanationItems.value.length) {
|
||||
return `当前报销已识别 ${riskCount} 个需核对风险点,用户已补充异常说明,审批人应核对说明与票据佐证是否充分。`
|
||||
}
|
||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}已识别 ${riskCount} 个需核对风险点,审批人应优先查看中高风险依据。`
|
||||
}
|
||||
if (materialIssues.value.length || sceneIssues.value.length) {
|
||||
@@ -127,24 +131,13 @@ export default {
|
||||
}
|
||||
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
|
||||
})
|
||||
const adviceItems = computed(() => {
|
||||
const fromRiskCards = currentRiskCards.value
|
||||
.map((card) => String(card?.suggestion || card?.risk || '').trim())
|
||||
.filter(Boolean)
|
||||
return uniqueTexts(fromRiskCards.length ? fromRiskCards : resolveDecision(decisionTone.value, props.isApplicationDocument).advice).slice(0, 4)
|
||||
})
|
||||
const compactAdviceItems = computed(() => adviceItems.value.slice(0, 2))
|
||||
|
||||
const stageEvidenceItems = computed(() => (
|
||||
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
|
||||
))
|
||||
const compactEvidenceItems = computed(() => {
|
||||
const abnormalItems = stageEvidenceItems.value.filter((item) => isAbnormalEvidence(item))
|
||||
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
|
||||
return sourceItems.slice(0, 3).map((item) => ({
|
||||
...item,
|
||||
evidence: item.evidence.slice(0, 2)
|
||||
}))
|
||||
return sourceItems.map((item) => ({ ...item }))
|
||||
})
|
||||
|
||||
function buildApplicationEvidence() {
|
||||
@@ -156,7 +149,7 @@ export default {
|
||||
`申请金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
|
||||
...riskTexts(amountCards)
|
||||
]),
|
||||
evidenceItem('apply_budget', '预算影响', budgetCards.length ? '需复核' : '未命中', budgetCards.length ? highestTone(budgetCards) : 'normal', (
|
||||
evidenceItem('apply_budget', '预算触发规则', budgetCards.length ? '需复核' : '未命中', budgetCards.length ? highestTone(budgetCards) : 'normal', (
|
||||
budgetCards.length ? riskTexts(budgetCards) : ['当前申请暂未命中预算余额或预算占用类中高风险。']
|
||||
)),
|
||||
evidenceItem('apply_scene', '申请事由与场景', sceneIssues.value.length ? '待补充' : '已说明', sceneIssues.value.length ? 'medium' : 'normal', [
|
||||
@@ -172,32 +165,39 @@ export default {
|
||||
}
|
||||
|
||||
function buildReimbursementEvidence() {
|
||||
const attachmentCards = currentRiskCards.value.filter((card) => /附件|票据|发票|OCR|识别|单据/.test(cardText(card)))
|
||||
const amountCards = currentRiskCards.value.filter((card) => /金额|标准|阈值|超标|不一致/.test(cardText(card)))
|
||||
const routeCards = currentRiskCards.value.filter((card) => /城市|行程|住宿|交通|出差|地点|日期|时间/.test(cardText(card)))
|
||||
const riskGroups = classifyReimbursementRiskCards(currentRiskCards.value)
|
||||
const attachmentCards = riskGroups.attachment
|
||||
const amountCards = riskGroups.amount
|
||||
const routeCards = riskGroups.route
|
||||
const otherCards = riskGroups.other
|
||||
const needAttachmentItems = currentItems.value.filter((item) => !item?.isSystemGenerated)
|
||||
const uploadedCount = needAttachmentItems.filter((item) => String(item?.invoiceId || '').trim()).length
|
||||
return [
|
||||
const evidenceItems = [
|
||||
evidenceItem('reimburse_attachment', '票据与附件', materialIssues.value.length || attachmentCards.length ? '需核对' : '完整', materialIssues.value.length || attachmentCards.length ? highestTone(attachmentCards, 'medium') : 'normal', [
|
||||
`需附件明细 ${needAttachmentItems.length} 条,已关联 ${uploadedCount} 条,未上传 ${materialIssues.value.length} 条。`,
|
||||
...materialIssues.value.slice(0, 3),
|
||||
...riskTexts(attachmentCards)
|
||||
]),
|
||||
evidenceItem('reimburse_amount', '报销金额与明细', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [
|
||||
...riskTexts(amountCards),
|
||||
...riskExplanationTexts(amountCards),
|
||||
`报销金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
|
||||
`费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}。`,
|
||||
...riskTexts(amountCards)
|
||||
`费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}。`
|
||||
]),
|
||||
evidenceItem('reimburse_route', '行程/时间/地点', routeCards.length || sceneIssues.value.length ? '需核对' : '已匹配', routeCards.length ? highestTone(routeCards) : sceneIssues.value.length ? 'medium' : 'normal', [
|
||||
...riskTexts(routeCards),
|
||||
...riskExplanationTexts(routeCards),
|
||||
`报销事由:${displayValue(requestModel.value.reason, '待补充')}`,
|
||||
`报销地点/目的地:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`,
|
||||
...sceneIssues.value.map((item) => `当前缺少:${item}`),
|
||||
...riskTexts(routeCards)
|
||||
]),
|
||||
evidenceItem('reimburse_risk', '报销规则命中', currentRiskCards.value.length ? '有风险' : '无异常', decisionTone.value, (
|
||||
currentRiskCards.value.length ? riskTexts(currentRiskCards.value) : ['报销环节未命中中高风险规则。']
|
||||
))
|
||||
...sceneIssues.value.map((item) => `当前缺少:${item}`)
|
||||
])
|
||||
]
|
||||
if (otherCards.length || (!amountCards.length && !routeCards.length && !attachmentCards.length)) {
|
||||
evidenceItems.push(evidenceItem('reimburse_risk', '其他规则命中', otherCards.length ? '有风险' : '无异常', otherCards.length ? highestTone(otherCards) : decisionTone.value, (
|
||||
otherCards.length ? riskTexts(otherCards) : ['报销环节未命中中高风险规则。']
|
||||
)))
|
||||
}
|
||||
return evidenceItems
|
||||
}
|
||||
|
||||
function evidenceItem(code, label, status, tone, evidence) {
|
||||
@@ -211,8 +211,6 @@ export default {
|
||||
}
|
||||
|
||||
return {
|
||||
adviceItems,
|
||||
compactAdviceItems,
|
||||
compactEvidenceItems,
|
||||
decisionBadgeLabel,
|
||||
decisionTone,
|
||||
@@ -232,18 +230,15 @@ function resolveDecision(tone, isApplicationDocument) {
|
||||
const map = {
|
||||
normal: {
|
||||
title: `当前${subject}未发现中高风险阻断项`,
|
||||
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`,
|
||||
advice: [`按当前${subject}信息、预算/票据结果和审批权限继续处理。`, '如审批人掌握额外业务背景,可在审批意见中补充。']
|
||||
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`
|
||||
},
|
||||
medium: {
|
||||
title: `当前${subject}存在中风险,建议核对后处理`,
|
||||
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。',
|
||||
advice: ['请优先核对橙色风险项对应的业务说明、金额和材料。', '信息补齐或说明充分后,再决定通过或退回。']
|
||||
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。'
|
||||
},
|
||||
high: {
|
||||
title: `当前${subject}存在高风险,不建议直接通过`,
|
||||
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。',
|
||||
advice: ['请优先处理红色高风险项,核对命中规则和业务佐证。', '若属于真实业务例外,应要求申请人补充原因和证明材料。']
|
||||
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。'
|
||||
}
|
||||
}
|
||||
return map[tone] || map.normal
|
||||
@@ -258,6 +253,40 @@ function isAbnormalEvidence(item) {
|
||||
return !['正常', '未命中', '已说明', '完整', '已匹配', '无异常'].includes(status)
|
||||
}
|
||||
|
||||
function classifyReimbursementRiskCards(cards = []) {
|
||||
const groups = {
|
||||
attachment: [],
|
||||
amount: [],
|
||||
route: [],
|
||||
other: []
|
||||
}
|
||||
for (const card of cards) {
|
||||
const text = cardText(card)
|
||||
if (isAmountRiskText(text)) {
|
||||
groups.amount.push(card)
|
||||
} else if (isRouteRiskText(text)) {
|
||||
groups.route.push(card)
|
||||
} else if (isAttachmentRiskText(text)) {
|
||||
groups.attachment.push(card)
|
||||
} else {
|
||||
groups.other.push(card)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function isAmountRiskText(value) {
|
||||
return /金额|标准|阈值|超标|超出|报销标准|住宿标准|差标|费用上限/.test(String(value || ''))
|
||||
}
|
||||
|
||||
function isRouteRiskText(value) {
|
||||
return /多城市|中转|多地|改签|城市|行程|交通|出差|地点|日期|时间|目的地|起始地|返回|火车|高铁|机票|航班/.test(String(value || ''))
|
||||
}
|
||||
|
||||
function isAttachmentRiskText(value) {
|
||||
return /附件|票据|发票|OCR|识别|单据/.test(String(value || ''))
|
||||
}
|
||||
|
||||
function matchesCurrentStage(card, isApplicationDocument) {
|
||||
const businessStage = resolveCardBusinessStage(card)
|
||||
if (businessStage) {
|
||||
@@ -367,11 +396,33 @@ function highestTone(cards, fallback = 'normal') {
|
||||
|
||||
function riskTexts(cards) {
|
||||
return cards
|
||||
.map((card) => String(card?.risk || card?.summary || card?.title || '').trim())
|
||||
.map((card) => stripEmbeddedExplanationText(card?.risk || card?.summary || card?.title || ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
function riskExplanationTexts(cards) {
|
||||
return uniqueTexts(cards.flatMap((card) => {
|
||||
const summary = String(card?.relatedExplanationSummary || '').trim()
|
||||
const entries = Array.isArray(card?.relatedExplanations)
|
||||
? card.relatedExplanations.map((item) => String(item?.text || item?.note || '').trim())
|
||||
: []
|
||||
if (summary) {
|
||||
return [`已补充异常说明:${summary}`]
|
||||
}
|
||||
return entries.map((item) => item ? `已补充异常说明:${item}` : '')
|
||||
})).slice(0, 4)
|
||||
}
|
||||
|
||||
function stripEmbeddedExplanationText(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/,?用户已在相关费用明细补充异常说明[^。;;]*[。;;]?/g, '')
|
||||
.replace(/,?用户已在费用明细补充异常说明[^。;;]*[。;;]?/g, '')
|
||||
.replace(/[,,;;。]+$/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function cardText(card) {
|
||||
return [
|
||||
card?.label,
|
||||
@@ -413,327 +464,4 @@ function uniqueTexts(values) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-risk-profile-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.employee-risk-head {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.employee-risk-title-wrap {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-card h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.detail-card-head h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.detail-card-title-with-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-card-title-with-icon i {
|
||||
margin-top: 1px;
|
||||
color: #334155;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill {
|
||||
height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #dbe4ee;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.normal {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-tone-pill.high {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong.normal {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong.medium {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong.high {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note,
|
||||
.employee-risk-profile {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note > span,
|
||||
.employee-risk-ai-main > span,
|
||||
.employee-risk-section-head span {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note strong,
|
||||
.employee-risk-ai-main strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.employee-risk-ai-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.high {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.medium strong {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.high strong {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-advice-list {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 4px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.employee-risk-action {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.employee-risk-action span {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-action strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.employee-risk-action strong.medium {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-action strong.high {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-advice-list p {
|
||||
margin: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid #cbd5e1;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-profile-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.employee-risk-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.employee-risk-section-head small {
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.employee-risk-profile-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.employee-risk-profile {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 142px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.employee-risk-profile.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fffaf4;
|
||||
}
|
||||
|
||||
.employee-risk-profile.high {
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 22px;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong {
|
||||
width: 48px;
|
||||
height: 20px;
|
||||
flex: 0 0 48px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list li {
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-muted {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.employee-risk-ai-note,
|
||||
.employee-risk-profile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.employee-risk-title-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../../assets/styles/components/stage-risk-advice-card.css"></style>
|
||||
|
||||
@@ -10,10 +10,36 @@
|
||||
:busy-text="busyText"
|
||||
confirm-tone="primary"
|
||||
confirm-icon="mdi mdi-check-circle-outline"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
:busy="busy"
|
||||
@close="emit('close')"
|
||||
@confirm="emit('confirm')"
|
||||
>
|
||||
<section v-if="riskConfirmRequired" class="approval-risk-confirm-panel" aria-label="风险说明确认">
|
||||
<div class="approval-risk-confirm-head">
|
||||
<span><i class="mdi mdi-alert-decagram-outline"></i>风险说明确认</span>
|
||||
<strong>{{ riskConfirmItems.length }} 项需核对</strong>
|
||||
</div>
|
||||
<ul v-if="riskConfirmItems.length" class="approval-risk-confirm-list">
|
||||
<li v-for="item in riskConfirmItems" :key="item.id || item.title">
|
||||
<em :class="item.tone">{{ item.label }}</em>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.description }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<label class="approval-risk-confirm-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="riskConfirmed"
|
||||
:disabled="busy"
|
||||
@change="handleRiskConfirmedChange"
|
||||
/>
|
||||
<span>我已核对风险说明、异常原因和佐证材料,确认继续当前审批动作。</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<label class="approval-opinion-field">
|
||||
<span>
|
||||
{{ opinionTitle }}
|
||||
@@ -53,19 +79,132 @@ const props = defineProps({
|
||||
opinion: { type: String, default: '' },
|
||||
opinionPlaceholder: { type: String, default: '' },
|
||||
opinionHint: { type: String, default: '' },
|
||||
opinionRequired: { type: Boolean, default: false }
|
||||
opinionRequired: { type: Boolean, default: false },
|
||||
riskConfirmRequired: { type: Boolean, default: false },
|
||||
riskConfirmed: { type: Boolean, default: false },
|
||||
riskConfirmItems: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'confirm', 'update:opinion'])
|
||||
const emit = defineEmits(['close', 'confirm', 'update:opinion', 'update:risk-confirmed'])
|
||||
|
||||
const currentOpinion = computed(() => String(props.opinion || ''))
|
||||
const confirmDisabled = computed(() => (
|
||||
(props.riskConfirmRequired && !props.riskConfirmed)
|
||||
|| (props.opinionRequired && !currentOpinion.value.trim())
|
||||
))
|
||||
|
||||
function handleOpinionInput(event) {
|
||||
emit('update:opinion', event.target.value)
|
||||
}
|
||||
|
||||
function handleRiskConfirmedChange(event) {
|
||||
emit('update:risk-confirmed', Boolean(event.target.checked))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approval-risk-confirm-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 4px;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-head span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #9a3412;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-head strong {
|
||||
color: #c2410c;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(251, 146, 60, 0.28);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list em {
|
||||
min-width: 46px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list em.high {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-check input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.approval-opinion-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
@@ -547,9 +547,14 @@ export function useAppShell() {
|
||||
router.push({ name: 'app-documents', query: buildDocumentReturnQuery() })
|
||||
}
|
||||
|
||||
async function handleRequestUpdated() {
|
||||
async function handleRequestUpdated(payload = {}) {
|
||||
if (payload?.claim && typeof payload.claim === 'object') {
|
||||
const mappedRequest = mapExpenseClaimToRequest(payload.claim)
|
||||
upsertRequestSnapshot(mappedRequest)
|
||||
}
|
||||
const claimId = String(payload?.claimId || payload?.claim_id || route.params.requestId || '').trim()
|
||||
await reloadWorkbenchRequests()
|
||||
await refreshSelectedRequestDetail(String(route.params.requestId || ''))
|
||||
await refreshSelectedRequestDetail(claimId)
|
||||
}
|
||||
|
||||
async function handleRequestDeleted(payload = {}) {
|
||||
|
||||
@@ -4,11 +4,11 @@ function readCurrentWebEndpoint(initialState) {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
host: initialState?.web?.host || '0.0.0.0',
|
||||
port: Number(initialState?.web?.port || 5173)
|
||||
port: Number(initialState?.web?.port || 5273)
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPort = Number(initialState?.web?.port || 5173)
|
||||
const fallbackPort = Number(initialState?.web?.port || 5273)
|
||||
const port = Number(window.location.port || fallbackPort)
|
||||
|
||||
return {
|
||||
|
||||
@@ -57,7 +57,7 @@ function readClientBootstrapState() {
|
||||
},
|
||||
web: {
|
||||
host: env.VITE_WEB_HOST || '0.0.0.0',
|
||||
port: Number(env.VITE_WEB_PORT || 5173)
|
||||
port: Number(env.VITE_WEB_PORT || 5273)
|
||||
},
|
||||
server: {
|
||||
host: env.VITE_SERVER_HOST || '0.0.0.0',
|
||||
|
||||
@@ -83,6 +83,55 @@ export const BUDGET_ONTOLOGY_FIELDS = [
|
||||
required: false,
|
||||
aliases: ['剩余可用', '可用余额', '剩余预算', '可用预算']
|
||||
},
|
||||
{
|
||||
key: 'claim_amount',
|
||||
label: '本单金额',
|
||||
scope: 'budget_execution',
|
||||
required: false,
|
||||
aliases: ['本单金额', '申请金额', '报销金额']
|
||||
},
|
||||
{
|
||||
key: 'claim_amount_ratio',
|
||||
label: '本单占用比例',
|
||||
scope: 'budget_execution',
|
||||
required: false,
|
||||
aliases: ['本单占用比例', '占用比例', '本次费用占预算']
|
||||
},
|
||||
{
|
||||
key: 'usage_rate',
|
||||
label: '当前使用率',
|
||||
scope: 'budget_execution',
|
||||
required: false,
|
||||
aliases: ['当前使用率', '预算使用率']
|
||||
},
|
||||
{
|
||||
key: 'after_usage_rate',
|
||||
label: '审批后使用率',
|
||||
scope: 'budget_execution',
|
||||
required: false,
|
||||
aliases: ['审批后使用率', '审批后预算使用率']
|
||||
},
|
||||
{
|
||||
key: 'remaining_budget_ratio',
|
||||
label: '剩余比例',
|
||||
scope: 'budget_execution',
|
||||
required: false,
|
||||
aliases: ['剩余比例', '预算剩余比例']
|
||||
},
|
||||
{
|
||||
key: 'available_before_amount',
|
||||
label: '审批前可用预算',
|
||||
scope: 'budget_execution',
|
||||
required: false,
|
||||
aliases: ['审批前可用预算', '审批前余额']
|
||||
},
|
||||
{
|
||||
key: 'over_budget_amount',
|
||||
label: '超预算金额',
|
||||
scope: 'budget_control',
|
||||
required: false,
|
||||
aliases: ['超预算金额', '超预算风险', '超出预算']
|
||||
},
|
||||
{
|
||||
key: 'warning_threshold',
|
||||
label: '预警线',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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('费用明细不能为空')
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -415,7 +415,7 @@ function normalizeState(env) {
|
||||
},
|
||||
web: {
|
||||
host: env.WEB_HOST || '0.0.0.0',
|
||||
port: Number(env.WEB_PORT || 5173)
|
||||
port: Number(env.WEB_PORT || 5273)
|
||||
},
|
||||
server: {
|
||||
host: env.SERVER_HOST || '0.0.0.0',
|
||||
@@ -488,7 +488,7 @@ function resolveRuntimePayload(payload, currentEnv) {
|
||||
return {
|
||||
...payload,
|
||||
web_host: webHost,
|
||||
web_port: Number(payload.web_port || currentEnv.WEB_PORT || 5173),
|
||||
web_port: Number(payload.web_port || currentEnv.WEB_PORT || 5273),
|
||||
server_host:
|
||||
normalizedWebHost &&
|
||||
normalizedWebHost !== '127.0.0.1' &&
|
||||
|
||||
@@ -70,7 +70,7 @@ if [ "${X_FINANCIAL_FORCE_SETUP:-false}" = "true" ]; then
|
||||
fi
|
||||
|
||||
WEB_HOST="${WEB_HOST:-0.0.0.0}"
|
||||
WEB_PORT="${WEB_PORT:-5173}"
|
||||
WEB_PORT="${WEB_PORT:-5273}"
|
||||
|
||||
export VITE_SETUP_COMPLETED="${SETUP_COMPLETED:-false}"
|
||||
export VITE_COMPANY_NAME="${COMPANY_NAME:-}"
|
||||
|
||||
Reference in New Issue
Block a user