feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选 - 引入 expense_claim_status_registry 统一报销状态流转 - 完善报销草稿流程、Item Sync 与本体解析器 - 优化总览页趋势图、分页组件与请求进度步骤 - 增强报销申请快速预览、本体工具与详情展示 - 新增半年报销模拟数据种子脚本与状态审计工具 - 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -136,12 +136,16 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.trend-panel,
|
||||
.rank-panel {
|
||||
.trend-panel {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.trend-count-panel,
|
||||
.donut-panel,
|
||||
.rank-panel,
|
||||
.employee-rank-panel,
|
||||
.top-claim-panel,
|
||||
.budget-metrics-panel,
|
||||
.bottleneck-panel,
|
||||
.budget-panel,
|
||||
.model-panel,
|
||||
@@ -149,6 +153,12 @@
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.bottleneck-panel,
|
||||
.budget-metrics-panel,
|
||||
.budget-panel {
|
||||
grid-column: span 6;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -404,7 +414,9 @@
|
||||
}
|
||||
|
||||
.bottleneck-panel,
|
||||
.budget-metrics-panel,
|
||||
.budget-panel,
|
||||
.top-claim-panel,
|
||||
.model-panel,
|
||||
.feedback-panel {
|
||||
display: flex;
|
||||
@@ -477,6 +489,142 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.budget-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.budget-metric-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f8fafc;
|
||||
animation: listRowIn 460ms var(--ease) both;
|
||||
animation-delay: var(--delay, 0ms);
|
||||
}
|
||||
|
||||
.budget-metric-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), .10);
|
||||
color: var(--theme-primary-active);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.budget-metric-item div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.budget-metric-item span:not(.budget-metric-icon),
|
||||
.budget-metric-item strong,
|
||||
.budget-metric-item em {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.budget-metric-item span:not(.budget-metric-icon) {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.budget-metric-item strong {
|
||||
margin-top: 5px;
|
||||
color: #0f172a;
|
||||
font-size: 16px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.budget-metric-item em {
|
||||
margin-top: 5px;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.budget-metric-item.warning {
|
||||
border-color: rgba(245, 158, 11, .26);
|
||||
background: rgba(245, 158, 11, .06);
|
||||
}
|
||||
|
||||
.budget-metric-item.warning .budget-metric-icon {
|
||||
background: rgba(245, 158, 11, .12);
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.budget-metric-item.danger {
|
||||
border-color: rgba(239, 68, 68, .26);
|
||||
background: rgba(239, 68, 68, .06);
|
||||
}
|
||||
|
||||
.budget-metric-item.danger .budget-metric-icon {
|
||||
background: rgba(239, 68, 68, .12);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.budget-metric-item.success .budget-metric-icon {
|
||||
background: rgba(var(--success-rgb), .10);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.top-claim-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.top-claim-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.top-claim-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.top-claim-row div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-claim-row div:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.top-claim-row strong,
|
||||
.top-claim-row span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.top-claim-row strong {
|
||||
color: #1e293b;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.top-claim-row span {
|
||||
margin-top: 4px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.bottleneck-list {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
@@ -610,6 +758,7 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.kpi-card,
|
||||
.dashboard-card,
|
||||
.budget-metric-item,
|
||||
.bottleneck-row {
|
||||
animation: none;
|
||||
}
|
||||
@@ -630,11 +779,15 @@
|
||||
}
|
||||
|
||||
.trend-panel,
|
||||
.rank-panel {
|
||||
.trend-count-panel,
|
||||
.rank-panel,
|
||||
.employee-rank-panel,
|
||||
.top-claim-panel {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
.donut-panel,
|
||||
.budget-metrics-panel,
|
||||
.bottleneck-panel,
|
||||
.budget-panel,
|
||||
.model-panel,
|
||||
@@ -694,8 +847,12 @@
|
||||
}
|
||||
|
||||
.trend-panel,
|
||||
.trend-count-panel,
|
||||
.rank-panel,
|
||||
.employee-rank-panel,
|
||||
.top-claim-panel,
|
||||
.donut-panel,
|
||||
.budget-metrics-panel,
|
||||
.bottleneck-panel,
|
||||
.budget-panel,
|
||||
.model-panel,
|
||||
@@ -716,6 +873,10 @@
|
||||
grid-template-columns: 24px 64px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.budget-metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.rank-value {
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<div class="trend-chart">
|
||||
<div class="chart-legend">
|
||||
<span><i :style="{ background: chartColors.primary }"></i>申请量(单)</span>
|
||||
<span><i :style="{ background: chartColors.blue }"></i>审批完成量(单)</span>
|
||||
<span><i :style="{ background: chartColors.purple }"></i>平均审批时长(小时)</span>
|
||||
<span><i :style="{ background: activeColor }"></i>{{ legendLabel }}</span>
|
||||
</div>
|
||||
<div ref="chartElement" class="chart-body" role="img" :aria-label="ariaLabel"></div>
|
||||
</div>
|
||||
@@ -23,22 +21,44 @@ use([GridComponent, TooltipComponent, EChartsBarChart, EChartsLineChart, CanvasR
|
||||
|
||||
const props = defineProps({
|
||||
labels: { type: Array, required: true },
|
||||
applications: { type: Array, required: true },
|
||||
approved: { type: Array, required: true },
|
||||
avgHours: { type: Array, required: true }
|
||||
mode: { type: String, default: 'amount' },
|
||||
claimCount: { type: Array, default: () => [] },
|
||||
claimAmount: { type: Array, default: () => [] },
|
||||
applications: { type: Array, default: () => [] },
|
||||
approved: { type: Array, default: () => [] },
|
||||
avgHours: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const chartElement = shallowRef(null)
|
||||
const themeColors = useThemeColors()
|
||||
const chartColors = computed(() => ({
|
||||
primary: themeColors.value.chartPrimary,
|
||||
blue: themeColors.value.chartBlue,
|
||||
purple: themeColors.value.chartPurple
|
||||
blue: themeColors.value.chartBlue
|
||||
}))
|
||||
const isCountMode = computed(() => props.mode === 'count')
|
||||
|
||||
const claimCountSeries = computed(() => (
|
||||
props.claimCount.length ? props.claimCount : props.applications
|
||||
))
|
||||
const claimAmountSeries = computed(() => (
|
||||
props.claimAmount.length ? props.claimAmount : props.approved
|
||||
))
|
||||
const activeSeries = computed(() => (
|
||||
isCountMode.value ? claimCountSeries.value : claimAmountSeries.value
|
||||
))
|
||||
const activeColor = computed(() => (
|
||||
isCountMode.value ? chartColors.value.primary : chartColors.value.blue
|
||||
))
|
||||
const legendLabel = computed(() => (
|
||||
isCountMode.value ? '报销数量(单)' : '报销金额(元)'
|
||||
))
|
||||
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1))
|
||||
|
||||
const ariaLabel = computed(() =>
|
||||
props.labels.map((label, index) => (
|
||||
`${label}申请${props.applications[index] || 0}单,审批${props.approved[index] || 0}单,平均${props.avgHours[index] || 0}小时`
|
||||
isCountMode.value
|
||||
? `${label}报销${claimCountSeries.value[index] || 0}单`
|
||||
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
|
||||
)).join(';')
|
||||
)
|
||||
|
||||
@@ -51,7 +71,7 @@ const chartOptions = computed(() => ({
|
||||
animationEasingUpdate: 'linear',
|
||||
grid: {
|
||||
top: 18,
|
||||
right: 38,
|
||||
right: 24,
|
||||
bottom: 22,
|
||||
left: 36,
|
||||
containLabel: true
|
||||
@@ -83,72 +103,46 @@ const chartOptions = computed(() => ({
|
||||
fontWeight: 700
|
||||
}
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 250,
|
||||
splitNumber: 5,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: Math.ceil(maxValue.value * 1.2),
|
||||
splitNumber: 5,
|
||||
name: isCountMode.value ? '单' : '元',
|
||||
nameTextStyle: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: 15,
|
||||
splitNumber: 5,
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700
|
||||
},
|
||||
splitLine: { show: false }
|
||||
}
|
||||
],
|
||||
axisLabel: {
|
||||
color: '#64748b',
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
|
||||
},
|
||||
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '申请量(单)',
|
||||
type: 'bar',
|
||||
data: props.applications,
|
||||
barWidth: 12,
|
||||
barGap: '28%',
|
||||
itemStyle: {
|
||||
color: chartColors.value.primary,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '审批完成量(单)',
|
||||
type: 'bar',
|
||||
data: props.approved,
|
||||
barWidth: 12,
|
||||
itemStyle: {
|
||||
color: chartColors.value.blue,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: '平均审批时长(小时)',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: props.avgHours,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
name: legendLabel.value,
|
||||
type: isCountMode.value ? 'line' : 'bar',
|
||||
data: activeSeries.value,
|
||||
barWidth: 16,
|
||||
smooth: isCountMode.value,
|
||||
symbol: isCountMode.value ? 'circle' : 'none',
|
||||
symbolSize: 7,
|
||||
lineStyle: {
|
||||
width: 2.5,
|
||||
color: chartColors.value.purple
|
||||
color: activeColor.value
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ffffff',
|
||||
borderColor: chartColors.value.purple,
|
||||
borderWidth: 2.5
|
||||
color: isCountMode.value ? '#ffffff' : activeColor.value,
|
||||
borderColor: activeColor.value,
|
||||
borderWidth: isCountMode.value ? 2.5 : 0,
|
||||
borderRadius: [4, 4, 0, 0]
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: isCountMode.value ? 1 : 0,
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
@@ -156,10 +150,15 @@ const chartOptions = computed(() => ({
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: toRgba(chartColors.value.purple, 0.14) },
|
||||
{ offset: 1, color: toRgba(chartColors.value.purple, 0.02) }
|
||||
{ offset: 0, color: toRgba(activeColor.value, 0.14) },
|
||||
{ offset: 1, color: toRgba(activeColor.value, 0.02) }
|
||||
]
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
valueFormatter: (value) => (
|
||||
isCountMode.value ? `${Number(value || 0)} 单` : formatCurrency(value)
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -178,6 +177,20 @@ function toRgba(color, alpha) {
|
||||
}
|
||||
return `rgba(58, 124, 165, ${alpha})`
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1000000) return `¥${(number / 1000000).toFixed(1)}M`
|
||||
if (number >= 1000) return `¥${(number / 1000).toFixed(1)}K`
|
||||
return `¥${Math.round(number)}`
|
||||
}
|
||||
|
||||
function formatAxisCurrency(value) {
|
||||
const number = Number(value || 0)
|
||||
if (number >= 1000000) return `${(number / 1000000).toFixed(1)}M`
|
||||
if (number >= 1000) return `${(number / 1000).toFixed(0)}K`
|
||||
return `${Math.round(number)}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -38,19 +38,36 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EnterpriseSelect
|
||||
v-if="showPageSize"
|
||||
class="page-size-select"
|
||||
:model-value="pageSize"
|
||||
:options="pageSizeOptions"
|
||||
size="small"
|
||||
@change="setPageSize"
|
||||
/>
|
||||
<div class="page-tools">
|
||||
<EnterpriseSelect
|
||||
v-if="showPageSize"
|
||||
class="page-size-select"
|
||||
:model-value="pageSize"
|
||||
:options="pageSizeOptions"
|
||||
size="small"
|
||||
@change="setPageSize"
|
||||
/>
|
||||
|
||||
<div class="page-jump">
|
||||
<span>跳至</span>
|
||||
<input
|
||||
:value="pageInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
aria-label="输入页码跳转"
|
||||
@blur="commitPageInput"
|
||||
@input="updatePageInput"
|
||||
@keydown.enter.prevent="commitPageInput"
|
||||
/>
|
||||
<span>页</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import EnterpriseSelect from './EnterpriseSelect.vue'
|
||||
|
||||
@@ -73,12 +90,15 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['update:currentPage', 'update:pageSize', 'page-size-change'])
|
||||
|
||||
const pageInput = ref(String(props.currentPage || 1))
|
||||
|
||||
const pageItems = computed(() => {
|
||||
if (props.pages.length) {
|
||||
return props.pages
|
||||
const total = Math.max(1, Number(props.totalPages) || 1)
|
||||
if (total <= 4) {
|
||||
return Array.from({ length: total }, (_, index) => index + 1)
|
||||
}
|
||||
|
||||
return Array.from({ length: props.totalPages }, (_, index) => index + 1)
|
||||
return [1, 2, 3, 'ellipsis', total]
|
||||
})
|
||||
|
||||
const summaryText = computed(() => {
|
||||
@@ -104,4 +124,21 @@ function setPageSize(size) {
|
||||
emit('update:pageSize', size)
|
||||
emit('page-size-change', size)
|
||||
}
|
||||
|
||||
function updatePageInput(event) {
|
||||
pageInput.value = String(event.target.value || '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function commitPageInput() {
|
||||
const nextPage = Math.min(Math.max(Number(pageInput.value) || props.currentPage || 1, 1), props.totalPages)
|
||||
pageInput.value = String(nextPage)
|
||||
setPage(nextPage)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.currentPage,
|
||||
(page) => {
|
||||
pageInput.value = String(page || 1)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -5,29 +5,26 @@ export function useLoginView() {
|
||||
const password = ref('')
|
||||
const tenant = ref('远光软件股份有限公司')
|
||||
const remember = ref(true)
|
||||
|
||||
const tenantOptions = [
|
||||
{
|
||||
label: '远光软件股份有限公司',
|
||||
value: '远光软件股份有限公司'
|
||||
}
|
||||
]
|
||||
const showPassword = ref(false)
|
||||
|
||||
const features = [
|
||||
{
|
||||
iconKey: 'recognition',
|
||||
title: '智能识别 自动归集',
|
||||
desc: '票据智能识别,自动归集费用,减少人工录入'
|
||||
title: '智能审单',
|
||||
desc: 'AI 自动识别票据与规则,提升准确率与处理效率',
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
tone: 'green'
|
||||
},
|
||||
{
|
||||
iconKey: 'workflow',
|
||||
title: '流程透明 合规可控',
|
||||
desc: '内置审批规则引擎,流程透明,风险可控'
|
||||
title: '异常预警',
|
||||
desc: '多维风险识别与预警,主动防控报销风险',
|
||||
icon: 'mdi mdi-bell-outline',
|
||||
tone: 'red'
|
||||
},
|
||||
{
|
||||
iconKey: 'insight',
|
||||
title: '数据洞察 决策支持',
|
||||
desc: '多维度费用分析,洞察业务,驱动决策'
|
||||
title: 'SLA 监控',
|
||||
desc: '实时监控服务水位,保障审批和处理时效',
|
||||
icon: 'mdi mdi-sync',
|
||||
tone: 'blue'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -52,8 +49,8 @@ export function useLoginView() {
|
||||
LogoMark,
|
||||
password,
|
||||
remember,
|
||||
showPassword,
|
||||
tenant,
|
||||
tenantOptions,
|
||||
username
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,16 +37,18 @@ import {
|
||||
} from '../data/metrics.js'
|
||||
|
||||
const emptyFinanceTotals = {
|
||||
pendingCount: 0,
|
||||
pendingAmount: 0,
|
||||
avgSla: 0,
|
||||
autoPassRate: 0,
|
||||
riskCount: 0,
|
||||
slaRate: 0
|
||||
reimbursementAmount: 0,
|
||||
reimbursementCount: 0,
|
||||
pendingPaymentAmount: 0,
|
||||
avgClaimAmount: 0,
|
||||
budgetUsageRate: 0,
|
||||
paymentClearanceRate: 0
|
||||
}
|
||||
|
||||
const emptyFinanceTrend = {
|
||||
labels: [],
|
||||
claimCount: [],
|
||||
claimAmount: [],
|
||||
applications: [],
|
||||
approved: [],
|
||||
avgHours: []
|
||||
@@ -63,6 +65,15 @@ const emptyFinanceBudgetSummary = {
|
||||
left: '¥0'
|
||||
}
|
||||
|
||||
const emptyFinanceBudgetMetrics = [
|
||||
{ label: '预算池数量', value: '0 个', detail: '年度有效预算池', tone: 'neutral', icon: 'mdi mdi-database-outline' },
|
||||
{ label: '总预算', value: '¥0', detail: '原始预算 + 调整', tone: 'neutral', icon: 'mdi mdi-cash-register' },
|
||||
{ label: '已用预算', value: '¥0', detail: '使用率 0.0%', tone: 'success', icon: 'mdi mdi-chart-arc' },
|
||||
{ label: '预占预算', value: '¥0', detail: '待流转单据占用', tone: 'success', icon: 'mdi mdi-lock-outline' },
|
||||
{ label: '可用预算', value: '¥0', detail: '可继续使用额度', tone: 'success', icon: 'mdi mdi-wallet-outline' },
|
||||
{ label: '预警预算池', value: '0 个', detail: '超支 0 个', tone: 'success', icon: 'mdi mdi-alert-outline' }
|
||||
]
|
||||
|
||||
export function useOverviewView(options = {}) {
|
||||
const activeTrendRange = ref(trendRanges[0])
|
||||
const activeDepartmentRange = ref(departmentRangeOptions[0])
|
||||
@@ -103,8 +114,9 @@ export function useOverviewView(options = {}) {
|
||||
const formatPercent = (value) => `${Math.round(Number(value || 0) * 100)}%`
|
||||
|
||||
const formatMetricValue = (metric, value) => {
|
||||
if (metric.key === 'pendingAmount') return formatCurrency(Math.round(value))
|
||||
if (metric.key === 'avgSla') return `${value.toFixed(1)} ${metric.unit}`
|
||||
if (['reimbursementAmount', 'pendingPaymentAmount', 'avgClaimAmount'].includes(metric.key)) {
|
||||
return formatCurrency(Math.round(value))
|
||||
}
|
||||
if (metric.unit === '%') return `${Math.round(value)} ${metric.unit}`
|
||||
if (metric.unit) return `${Math.round(value)} ${metric.unit}`
|
||||
return `${Math.round(value)}`
|
||||
@@ -311,12 +323,21 @@ export function useOverviewView(options = {}) {
|
||||
const financeDepartmentRanking = computed(() => (
|
||||
financeDashboardPayload.value?.departmentRanking || []
|
||||
))
|
||||
const financeEmployeeRanking = computed(() => (
|
||||
financeDashboardPayload.value?.employeeRanking || []
|
||||
))
|
||||
const financeTopClaims = computed(() => (
|
||||
financeDashboardPayload.value?.topClaims || []
|
||||
))
|
||||
const financeBottlenecks = computed(() => (
|
||||
financeDashboardPayload.value?.bottlenecks || []
|
||||
))
|
||||
const financeBudgetSummary = computed(() => (
|
||||
financeDashboardPayload.value?.budgetSummary || emptyFinanceBudgetSummary
|
||||
))
|
||||
const financeBudgetMetrics = computed(() => (
|
||||
financeDashboardPayload.value?.budgetMetrics || emptyFinanceBudgetMetrics
|
||||
))
|
||||
|
||||
const resolveSystemMetricMeta = (metric) => {
|
||||
const totals = systemDashboardTotals.value
|
||||
@@ -508,13 +529,15 @@ export function useOverviewView(options = {}) {
|
||||
})))
|
||||
|
||||
const rankedDepartments = computed(() => {
|
||||
const rows = financeDepartmentRanking.value.map((item) => ({
|
||||
...item,
|
||||
amount: Number(item.amount || item.value || 0)
|
||||
}))
|
||||
const rows = financeDepartmentRanking.value
|
||||
.filter((item) => !isMissingDimension(item.name))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
amount: Number(item.amount || item.value || 0)
|
||||
}))
|
||||
const max = Math.max(...rows.map((item) => item.amount), 1)
|
||||
|
||||
return rows.slice(0, 5).map((item, index) => ({
|
||||
return rows.slice(0, 6).map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
shortName: item.name,
|
||||
@@ -524,6 +547,32 @@ export function useOverviewView(options = {}) {
|
||||
}))
|
||||
})
|
||||
|
||||
const rankedEmployees = computed(() => {
|
||||
const rows = financeEmployeeRanking.value
|
||||
.filter((item) => !isMissingDimension(item.name))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
amount: Number(item.amount || item.value || 0)
|
||||
}))
|
||||
const max = Math.max(...rows.map((item) => item.amount), 1)
|
||||
|
||||
return rows.slice(0, 6).map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
shortName: item.name,
|
||||
amountLabel: formatCurrency(item.amount),
|
||||
width: `${Math.max((item.amount / max) * 100, 18)}%`,
|
||||
color: item.color
|
||||
}))
|
||||
})
|
||||
|
||||
const topClaims = computed(() => (
|
||||
financeTopClaims.value.map((item) => ({
|
||||
...item,
|
||||
amountLabel: item.amountLabel || formatCurrency(Number(item.amount || 0))
|
||||
}))
|
||||
))
|
||||
|
||||
const systemToolRankingItems = computed(() => systemToolRankings.map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
@@ -670,8 +719,14 @@ export function useOverviewView(options = {}) {
|
||||
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
|
||||
}
|
||||
|
||||
function isMissingDimension(value) {
|
||||
const text = String(value || '').trim()
|
||||
return !text || ['待补充', '待确认', '未归属部门', '未归属', 'N/A', 'n/a', '-'].includes(text)
|
||||
}
|
||||
|
||||
const bottlenecks = financeBottlenecks
|
||||
const budgetSummary = financeBudgetSummary
|
||||
const budgetMetrics = financeBudgetMetrics
|
||||
const spendByCategory = financeSpendByCategory
|
||||
const exceptionMix = financeExceptionMix
|
||||
|
||||
@@ -681,6 +736,7 @@ export function useOverviewView(options = {}) {
|
||||
activeTrend,
|
||||
activeTrendRange,
|
||||
bottlenecks,
|
||||
budgetMetrics,
|
||||
budgetSummary,
|
||||
departmentRangeOptions,
|
||||
digitalEmployeeCategoryRows,
|
||||
@@ -701,6 +757,7 @@ export function useOverviewView(options = {}) {
|
||||
kpiMetrics,
|
||||
metricBlueprints,
|
||||
rankedDepartments,
|
||||
rankedEmployees,
|
||||
riskDashboard,
|
||||
riskDashboardError,
|
||||
riskDashboardLoading,
|
||||
@@ -743,6 +800,7 @@ export function useOverviewView(options = {}) {
|
||||
systemToolRankings,
|
||||
systemToolTotal,
|
||||
systemTrendSeries,
|
||||
topClaims,
|
||||
trendRanges
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,14 @@ const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
|
||||
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
|
||||
const DOCUMENT_BACKED_EXPENSE_TYPES = new Set([
|
||||
'train_ticket',
|
||||
'flight_ticket',
|
||||
'ship_ticket',
|
||||
'ferry_ticket',
|
||||
'hotel_ticket',
|
||||
'ride_ticket'
|
||||
])
|
||||
const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
const RELATED_APPLICATION_STEP_LABEL = '关联单据'
|
||||
@@ -258,6 +266,83 @@ function resolveAttachmentDisplayName(value) {
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
function hasRelatedApplicationContext(claim) {
|
||||
return Boolean(findRelatedApplicationEvent(claim))
|
||||
}
|
||||
|
||||
function isDocumentBackedRawExpenseItem(item) {
|
||||
const invoiceId = normalizeText(item?.invoice_id || item?.invoiceId)
|
||||
if (invoiceId) {
|
||||
return true
|
||||
}
|
||||
|
||||
return DOCUMENT_BACKED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
|
||||
}
|
||||
|
||||
function extractTravelDayCount(value) {
|
||||
const matched = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
|
||||
return matched ? parseNumber(matched[1]) : 0
|
||||
}
|
||||
|
||||
function isStaleApplicationAllowanceRawItem(item, claim) {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (itemType !== 'travel_allowance') {
|
||||
return false
|
||||
}
|
||||
|
||||
const related = resolveRelatedApplicationInfo(claim)
|
||||
const applicationDays = extractTravelDayCount(related?.days)
|
||||
const itemDays = extractTravelDayCount(item?.item_reason || item?.itemReason)
|
||||
return applicationDays > 0 && itemDays > 0 && applicationDays !== itemDays
|
||||
}
|
||||
|
||||
function isApplicationLinkPlaceholderRawItem(item, claim) {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const claimType = normalizeExpenseType(claim?.expense_type || claim?.expenseType)
|
||||
if (itemType && claimType && itemType !== claimType) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reason = normalizeText(item?.item_reason || item?.itemReason)
|
||||
if (!reason || reason === '待补充') {
|
||||
return true
|
||||
}
|
||||
|
||||
const related = resolveRelatedApplicationInfo(claim)
|
||||
const linkedReasons = new Set([
|
||||
normalizeText(claim?.reason),
|
||||
normalizeText(related?.reason)
|
||||
].filter(Boolean))
|
||||
return linkedReasons.has(reason)
|
||||
}
|
||||
|
||||
function filterVisibleExpenseRawItems(items, claim) {
|
||||
const rawItems = Array.isArray(items) ? items : []
|
||||
if (!rawItems.length || !hasRelatedApplicationContext(claim)) {
|
||||
return rawItems
|
||||
}
|
||||
|
||||
const hasRealExpenseItem = rawItems.some((item) => (
|
||||
isDocumentBackedRawExpenseItem(item)
|
||||
&& !SYSTEM_GENERATED_EXPENSE_TYPES.has(normalizeExpenseType(item?.item_type || item?.itemType))
|
||||
))
|
||||
if (!hasRealExpenseItem) {
|
||||
return rawItems.filter((item) => !isApplicationLinkPlaceholderRawItem(item, claim))
|
||||
}
|
||||
|
||||
return rawItems.filter((item) => {
|
||||
const itemType = normalizeExpenseType(item?.item_type || item?.itemType)
|
||||
if (SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) {
|
||||
return !isStaleApplicationAllowanceRawItem(item, claim)
|
||||
}
|
||||
return !isApplicationLinkPlaceholderRawItem(item, claim)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveApprovalMeta(status) {
|
||||
const normalized = String(status || '').trim().toLowerCase()
|
||||
|
||||
@@ -617,6 +702,33 @@ function resolveApplicationField(flag = {}, detail = {}, snakeKey, camelKey = ''
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplicationValue(flag = {}, detail = {}, keys = []) {
|
||||
for (const key of keys) {
|
||||
const detailValue = normalizeText(detail?.[key])
|
||||
if (detailValue) {
|
||||
return detailValue
|
||||
}
|
||||
const flagValue = normalizeText(flag?.[key])
|
||||
if (flagValue) {
|
||||
return flagValue
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractDateRange(value) {
|
||||
const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
|
||||
if (!dates.length) {
|
||||
return { startDate: '', endDate: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: dates[0],
|
||||
endDate: dates[dates.length - 1]
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRelatedApplicationClaimNo(flag = {}) {
|
||||
const detail = normalizeApplicationHandoffDetail(flag)
|
||||
return resolveApplicationField(flag, detail, 'application_claim_no', 'applicationClaimNo')
|
||||
@@ -694,15 +806,41 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|
||||
const rawTime = normalizeText(
|
||||
detail.application_time
|
||||
|| detail.applicationTime
|
||||
|| detail.application_business_time
|
||||
|| detail.applicationBusinessTime
|
||||
|| detail.business_time
|
||||
|| detail.businessTime
|
||||
|| detail.time_range
|
||||
|| detail.timeRange
|
||||
|| detail.time
|
||||
|| detail.application_date
|
||||
|| detail.applicationDate
|
||||
|| relatedEvent.application_time
|
||||
|| relatedEvent.applicationTime
|
||||
|| relatedEvent.application_business_time
|
||||
|| relatedEvent.applicationBusinessTime
|
||||
|| relatedEvent.business_time
|
||||
|| relatedEvent.businessTime
|
||||
|| relatedEvent.time_range
|
||||
|| relatedEvent.timeRange
|
||||
|| relatedEvent.application_date
|
||||
|| relatedEvent.applicationDate
|
||||
|| claim?.occurred_at
|
||||
)
|
||||
const displayTime = formatDate(rawTime) || rawTime
|
||||
const dateRange = extractDateRange(rawTime || displayTime)
|
||||
const ruleName = resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_rule_name',
|
||||
'applicationRuleName',
|
||||
'rule_name',
|
||||
'ruleName'
|
||||
])
|
||||
const ruleVersion = resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_rule_version',
|
||||
'applicationRuleVersion',
|
||||
'rule_version',
|
||||
'ruleVersion'
|
||||
])
|
||||
|
||||
return {
|
||||
id: resolveApplicationField(relatedEvent, detail, 'application_claim_id', 'applicationClaimId'),
|
||||
@@ -717,7 +855,9 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|
||||
|| relatedEvent.applicationDays
|
||||
),
|
||||
location,
|
||||
time: formatDate(rawTime) || rawTime,
|
||||
time: displayTime,
|
||||
tripStartDate: dateRange.startDate,
|
||||
tripEndDate: dateRange.endDate,
|
||||
amountLabel: resolveRelatedApplicationAmountLabel(relatedEvent, detail, claim),
|
||||
statusLabel: resolveApplicationField(relatedEvent, detail, 'application_status_label', 'applicationStatusLabel'),
|
||||
transportMode: normalizeText(
|
||||
@@ -726,7 +866,34 @@ function resolveRelatedApplicationInfo(claim, typeLabel = '') {
|
||||
|| detail.transport_mode
|
||||
|| relatedEvent.application_transport_mode
|
||||
|| relatedEvent.applicationTransportMode
|
||||
)
|
||||
),
|
||||
lodgingDailyCap: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_lodging_daily_cap',
|
||||
'applicationLodgingDailyCap',
|
||||
'lodging_daily_cap',
|
||||
'lodgingDailyCap'
|
||||
]),
|
||||
subsidyDailyCap: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_subsidy_daily_cap',
|
||||
'applicationSubsidyDailyCap',
|
||||
'subsidy_daily_cap',
|
||||
'subsidyDailyCap'
|
||||
]),
|
||||
transportPolicy: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_transport_policy',
|
||||
'applicationTransportPolicy',
|
||||
'transport_policy',
|
||||
'transportPolicy'
|
||||
]),
|
||||
policyEstimate: resolveApplicationValue(relatedEvent, detail, [
|
||||
'application_policy_estimate',
|
||||
'applicationPolicyEstimate',
|
||||
'policy_estimate',
|
||||
'policyEstimate'
|
||||
]),
|
||||
ruleName,
|
||||
ruleVersion,
|
||||
ruleLabel: [ruleName, ruleVersion].filter(Boolean).join(' / ')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1056,7 +1223,8 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
return []
|
||||
}
|
||||
|
||||
const sortedItems = [...claim.items].sort((left, right) => {
|
||||
const visibleItems = filterVisibleExpenseRawItems(claim.items, claim)
|
||||
const sortedItems = [...visibleItems].sort((left, right) => {
|
||||
const leftType = normalizeExpenseType(left?.item_type)
|
||||
const rightType = normalizeExpenseType(right?.item_type)
|
||||
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
|
||||
@@ -1121,9 +1289,17 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
const workflowNode = resolveWorkflowNode(claim, approvalMeta, isApplicationDocument)
|
||||
const invoiceCount = Math.max(0, parseNumber(claim?.invoice_count))
|
||||
const riskSummary = buildRiskSummary(claim?.risk_flags_json)
|
||||
const expenseItems = buildExpenseItems(claim, riskSummary)
|
||||
const applyDateTime = claim?.submitted_at || claim?.created_at
|
||||
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
|
||||
const expenseItems = buildExpenseItems(claim, riskSummary)
|
||||
const visibleExpenseAmount = expenseItems.reduce((sum, item) => sum + parseNumber(item.itemAmount), 0)
|
||||
const amountValue = relatedApplication
|
||||
? expenseItems.length
|
||||
? visibleExpenseAmount
|
||||
: invoiceCount === 0
|
||||
? 0
|
||||
: parseNumber(claim?.amount)
|
||||
: parseNumber(claim?.amount)
|
||||
const applyDateTime = claim?.submitted_at || claim?.created_at
|
||||
const employeeId = String(claim?.employee_id || claim?.employeeId || '').trim()
|
||||
const employeeName = String(claim?.employee_name || claim?.employeeName || '').trim()
|
||||
|
||||
@@ -1162,7 +1338,7 @@ export function mapExpenseClaimToRequest(claim) {
|
||||
submittedAt: applyDateTime || '',
|
||||
createdAt: claim?.created_at || '',
|
||||
updatedAt: claim?.updated_at || '',
|
||||
amount: parseNumber(claim?.amount),
|
||||
amount: amountValue,
|
||||
riskFlags: Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : [],
|
||||
invoiceCount,
|
||||
workflowNode,
|
||||
|
||||
@@ -1,62 +1,60 @@
|
||||
export const metricBlueprints = [
|
||||
{
|
||||
key: 'pendingCount',
|
||||
label: '待审批单据',
|
||||
unit: '单',
|
||||
key: 'reimbursementAmount',
|
||||
label: '本期报销金额',
|
||||
accent: 'var(--theme-primary)',
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
trend: 'down',
|
||||
change: '12.5%',
|
||||
delta: '较昨日 -18 单'
|
||||
},
|
||||
{
|
||||
key: 'pendingAmount',
|
||||
label: '待处理金额',
|
||||
accent: 'var(--chart-blue)',
|
||||
icon: 'mdi mdi-wallet',
|
||||
icon: 'mdi mdi-cash-multiple',
|
||||
trend: 'up',
|
||||
change: '8.3%',
|
||||
delta: '较昨日 +¥27,400'
|
||||
change: '8.4%',
|
||||
delta: '较上一周期 +¥42.8K'
|
||||
},
|
||||
{
|
||||
key: 'avgSla',
|
||||
label: '平均审批时长',
|
||||
unit: 'h',
|
||||
accent: 'var(--chart-purple)',
|
||||
icon: 'mdi mdi-clock-outline',
|
||||
trend: 'down',
|
||||
change: '14.8%',
|
||||
delta: '较昨日 -1.2h'
|
||||
},
|
||||
{
|
||||
key: 'autoPassRate',
|
||||
label: '自动审单通过率',
|
||||
unit: '%',
|
||||
accent: 'var(--success)',
|
||||
icon: 'mdi mdi-shield-outline',
|
||||
trend: 'up',
|
||||
change: '6.2%',
|
||||
delta: '较昨日 +4.6%'
|
||||
},
|
||||
{
|
||||
key: 'riskCount',
|
||||
label: '异常预警单',
|
||||
key: 'reimbursementCount',
|
||||
label: '报销单数',
|
||||
unit: '单',
|
||||
accent: 'var(--danger)',
|
||||
icon: 'mdi mdi-alert',
|
||||
accent: 'var(--chart-blue)',
|
||||
icon: 'mdi mdi-file-document-outline',
|
||||
trend: 'up',
|
||||
change: '16.7%',
|
||||
delta: '较昨日 +2 单'
|
||||
change: '6.1%',
|
||||
delta: '较上一周期 +23 单'
|
||||
},
|
||||
{
|
||||
key: 'slaRate',
|
||||
label: 'SLA 达成率',
|
||||
key: 'pendingPaymentAmount',
|
||||
label: '待付款金额',
|
||||
accent: 'var(--chart-purple)',
|
||||
icon: 'mdi mdi-bank-transfer-out',
|
||||
trend: 'down',
|
||||
change: '4.7%',
|
||||
delta: '较上一周期 -¥18.3K'
|
||||
},
|
||||
{
|
||||
key: 'avgClaimAmount',
|
||||
label: '单均金额',
|
||||
accent: 'var(--chart-amber)',
|
||||
icon: 'mdi mdi-calculator-variant-outline',
|
||||
trend: 'up',
|
||||
change: '2.8%',
|
||||
delta: '较上一周期 +¥180'
|
||||
},
|
||||
{
|
||||
key: 'budgetUsageRate',
|
||||
label: '预算使用率',
|
||||
unit: '%',
|
||||
accent: 'var(--success)',
|
||||
icon: 'mdi mdi-check-circle',
|
||||
icon: 'mdi mdi-chart-arc',
|
||||
trend: 'up',
|
||||
change: '3.1%',
|
||||
delta: '较昨日 +2.9%'
|
||||
change: '3.2%',
|
||||
delta: '预算池汇总'
|
||||
},
|
||||
{
|
||||
key: 'paymentClearanceRate',
|
||||
label: '付款完成率',
|
||||
unit: '%',
|
||||
accent: 'var(--success)',
|
||||
icon: 'mdi mdi-check-circle-outline',
|
||||
trend: 'up',
|
||||
change: '5.5%',
|
||||
delta: '已付款 / 有效单据'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -127,20 +125,26 @@ export const trendRanges = ['近12天', '近7天', '近30天']
|
||||
export const trendSeries = {
|
||||
'近12天': {
|
||||
labels: ['07-01', '07-02', '07-03', '07-04', '07-05', '07-06', '07-07', '07-08', '07-09', '07-10', '07-12'],
|
||||
applications: [140, 105, 175, 195, 155, 70, 65, 60, 185, 200, 220],
|
||||
approved: [110, 85, 130, 125, 110, 60, 55, 50, 145, 150, 170],
|
||||
claimCount: [14, 11, 18, 20, 16, 7, 7, 6, 19, 20, 22],
|
||||
claimAmount: [38600, 31200, 49600, 55200, 44800, 19600, 20800, 18200, 56300, 60400, 68100],
|
||||
applications: [14, 11, 18, 20, 16, 7, 7, 6, 19, 20, 22],
|
||||
approved: [11, 9, 13, 13, 11, 6, 6, 5, 15, 15, 17],
|
||||
avgHours: [10, 8, 9, 7, 7, 6.8, 6, 6.5, 7, 8, 7.5]
|
||||
},
|
||||
'近7天': {
|
||||
labels: ['04-23', '04-24', '04-25', '04-26', '04-27', '04-28', '04-29'],
|
||||
applications: [72, 68, 109, 121, 134, 142, 128],
|
||||
approved: [58, 54, 92, 101, 116, 121, 110],
|
||||
claimCount: [7, 7, 11, 12, 13, 14, 13],
|
||||
claimAmount: [22100, 20600, 33800, 36200, 41600, 43800, 39700],
|
||||
applications: [7, 7, 11, 12, 13, 14, 13],
|
||||
approved: [6, 5, 9, 10, 12, 12, 11],
|
||||
avgHours: [6.9, 6.5, 6.8, 7.1, 7.4, 7.0, 6.8]
|
||||
},
|
||||
'近30天': {
|
||||
labels: ['03-31', '04-03', '04-06', '04-09', '04-12', '04-15', '04-18', '04-21', '04-24', '04-27'],
|
||||
applications: [82, 90, 96, 114, 120, 111, 126, 132, 119, 138],
|
||||
approved: [68, 76, 80, 95, 100, 93, 102, 110, 101, 117],
|
||||
claimCount: [8, 9, 10, 11, 12, 11, 13, 13, 12, 14],
|
||||
claimAmount: [24600, 27900, 29200, 35100, 38200, 33600, 40100, 42800, 36500, 44700],
|
||||
applications: [8, 9, 10, 11, 12, 11, 13, 13, 12, 14],
|
||||
approved: [7, 8, 8, 10, 10, 9, 10, 11, 10, 12],
|
||||
avgHours: [9.2, 8.8, 8.4, 8.0, 7.7, 7.4, 7.2, 6.9, 6.8, 6.7]
|
||||
}
|
||||
}
|
||||
@@ -153,38 +157,38 @@ export const spendByCategory = [
|
||||
]
|
||||
|
||||
export const exceptionMix = [
|
||||
{ name: '住宿超标', value: 5, color: 'var(--danger)' },
|
||||
{ name: '重复报销', value: 3, color: 'var(--warning)' },
|
||||
{ name: '行程缺失', value: 3, color: 'var(--chart-purple)' },
|
||||
{ name: '发票异常', value: 3, color: 'var(--chart-blue)' }
|
||||
{ name: '已付款', value: 68, color: 'var(--success)' },
|
||||
{ name: '待付款', value: 18, color: 'var(--chart-amber)' },
|
||||
{ name: '审批中', value: 12, color: 'var(--theme-primary)' },
|
||||
{ name: '已入账', value: 9, color: 'var(--chart-blue)' }
|
||||
]
|
||||
|
||||
export const departmentRangeOptions = ['本周', '本月', '本季度']
|
||||
|
||||
export const bottlenecks = [
|
||||
{
|
||||
name: '李文静',
|
||||
role: '财务经理',
|
||||
duration: '12.4 h',
|
||||
status: '较慢',
|
||||
name: '预算超支',
|
||||
role: '预算控制',
|
||||
duration: '3 个池',
|
||||
status: '¥42.6K',
|
||||
tone: 'danger',
|
||||
avatar: '李'
|
||||
avatar: '超'
|
||||
},
|
||||
{
|
||||
name: '王志强',
|
||||
role: '财务专员',
|
||||
duration: '8.7 h',
|
||||
status: '偏慢',
|
||||
name: '待付款',
|
||||
role: '资金计划',
|
||||
duration: '¥86.3K',
|
||||
status: '18 单',
|
||||
tone: 'warning',
|
||||
avatar: '王'
|
||||
avatar: '付'
|
||||
},
|
||||
{
|
||||
name: '刘思雨',
|
||||
role: '费用审核员',
|
||||
duration: '5.2 h',
|
||||
status: '正常',
|
||||
tone: 'success',
|
||||
avatar: '刘'
|
||||
name: '高额单据',
|
||||
role: '费用集中度',
|
||||
duration: '¥18.6K',
|
||||
status: '本期最高',
|
||||
tone: 'warning',
|
||||
avatar: '高'
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -20,8 +20,11 @@ const FINANCE_DASHBOARD_FALLBACK = {
|
||||
spendByCategory: null,
|
||||
exceptionMix: null,
|
||||
departmentRanking: null,
|
||||
employeeRanking: null,
|
||||
topClaims: null,
|
||||
bottlenecks: null,
|
||||
budgetSummary: null,
|
||||
budgetMetrics: null,
|
||||
hasRealData: false
|
||||
}
|
||||
|
||||
@@ -66,8 +69,11 @@ function normalizeFinanceDashboardPayload(payload = {}) {
|
||||
spendByCategory: payload.spend_by_category || payload.spendByCategory || null,
|
||||
exceptionMix: payload.exception_mix || payload.exceptionMix || null,
|
||||
departmentRanking: payload.department_ranking || payload.departmentRanking || null,
|
||||
employeeRanking: payload.employee_ranking || payload.employeeRanking || null,
|
||||
topClaims: payload.top_claims || payload.topClaims || null,
|
||||
bottlenecks: payload.bottlenecks || null,
|
||||
budgetSummary: payload.budget_summary || payload.budgetSummary || null
|
||||
budgetSummary: payload.budget_summary || payload.budgetSummary || null,
|
||||
budgetMetrics: payload.budget_metrics || payload.budgetMetrics || null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,92 @@ function pickDetailValue(detail, request, keys = [], fallback = '') {
|
||||
return normalizeText(fallback)
|
||||
}
|
||||
|
||||
function isTravelApplicationDetail(detail = {}, request = {}) {
|
||||
const typeText = [
|
||||
detail.application_type,
|
||||
detail.applicationType,
|
||||
request.typeCode,
|
||||
request.typeLabel,
|
||||
request.documentTypeLabel
|
||||
].map(normalizeText).join(' ')
|
||||
return /travel_application|差旅|出差/.test(typeText)
|
||||
}
|
||||
|
||||
function isEntertainmentApplicationDetail(detail = {}, request = {}) {
|
||||
const typeText = [
|
||||
detail.application_type,
|
||||
detail.applicationType,
|
||||
request.typeCode,
|
||||
request.typeLabel
|
||||
].map(normalizeText).join(' ')
|
||||
return /entertainment|招待/.test(typeText)
|
||||
}
|
||||
|
||||
function extractDateRange(value) {
|
||||
const dates = normalizeText(value).match(/\d{4}-\d{2}-\d{2}/g) || []
|
||||
return {
|
||||
startDate: dates[0] || '',
|
||||
endDate: dates[dates.length - 1] || ''
|
||||
}
|
||||
}
|
||||
|
||||
function extractDayCount(value) {
|
||||
const match = normalizeText(value).replace(/\s+/g, '').match(/(\d{1,2})天/)
|
||||
return match ? Number(match[1]) || 0 : 0
|
||||
}
|
||||
|
||||
function addDays(dateText, days) {
|
||||
if (!dateText || days <= 1) {
|
||||
return dateText
|
||||
}
|
||||
const date = new Date(`${dateText}T00:00:00`)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return dateText
|
||||
}
|
||||
date.setDate(date.getDate() + days - 1)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
function buildApplicationTimeRows(detail, request) {
|
||||
const timeValue = pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
|
||||
if (!isProvided(timeValue)) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (isTravelApplicationDetail(detail, request)) {
|
||||
const days = extractDayCount(pickDetailValue(detail, request, ['days']))
|
||||
const range = extractDateRange(timeValue)
|
||||
const startDate = range.startDate || timeValue
|
||||
const endDate = range.endDate && range.endDate !== range.startDate
|
||||
? range.endDate
|
||||
: addDays(range.startDate, days)
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'trip_start_time',
|
||||
label: '出发时间',
|
||||
value: startDate
|
||||
},
|
||||
{
|
||||
key: 'trip_return_time',
|
||||
label: '返回时间',
|
||||
value: endDate
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'time',
|
||||
label: isEntertainmentApplicationDetail(detail, request) ? '招待时间' : '申请时间',
|
||||
value: timeValue
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export function buildApplicationDetailFactItems(request = {}) {
|
||||
const detail = resolveApplicationDetailPayload(request)
|
||||
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
|
||||
@@ -39,11 +125,7 @@ export function buildApplicationDetailFactItems(request = {}) {
|
||||
label: '申请类型',
|
||||
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: '发生时间',
|
||||
value: pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
|
||||
},
|
||||
...buildApplicationTimeRows(detail, request),
|
||||
{
|
||||
key: 'location',
|
||||
label: '地点',
|
||||
@@ -107,6 +189,12 @@ export function buildApplicationDetailFactItems(request = {}) {
|
||||
|
||||
export function buildRelatedApplicationFactItems(request = {}) {
|
||||
const related = request.relatedApplication || {}
|
||||
const relatedRange = extractDateRange(related.time)
|
||||
const relatedStartDate = normalizeText(related.tripStartDate) || relatedRange.startDate
|
||||
const relatedEndDate = normalizeText(related.tripEndDate) || relatedRange.endDate || addDays(
|
||||
relatedStartDate,
|
||||
extractDayCount(related.days)
|
||||
)
|
||||
const rows = [
|
||||
{
|
||||
key: 'claim_no',
|
||||
@@ -119,6 +207,16 @@ export function buildRelatedApplicationFactItems(request = {}) {
|
||||
label: '申请内容',
|
||||
value: related.content
|
||||
},
|
||||
{
|
||||
key: 'trip_start_date',
|
||||
label: '出发时间',
|
||||
value: relatedStartDate
|
||||
},
|
||||
{
|
||||
key: 'trip_end_date',
|
||||
label: '返回时间',
|
||||
value: relatedEndDate
|
||||
},
|
||||
{
|
||||
key: 'days',
|
||||
label: '申请天数',
|
||||
@@ -135,9 +233,37 @@ export function buildRelatedApplicationFactItems(request = {}) {
|
||||
value: related.location
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: '申请时间',
|
||||
value: related.time
|
||||
key: 'transport_mode',
|
||||
label: '出行方式',
|
||||
value: related.transportMode
|
||||
},
|
||||
{
|
||||
key: 'lodging_daily_cap',
|
||||
label: '住宿上限/天',
|
||||
value: related.lodgingDailyCap,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'subsidy_daily_cap',
|
||||
label: '补贴标准/天',
|
||||
value: related.subsidyDailyCap,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'transport_policy',
|
||||
label: '交通费用口径',
|
||||
value: related.transportPolicy
|
||||
},
|
||||
{
|
||||
key: 'policy_estimate',
|
||||
label: '规则测算参考',
|
||||
value: related.policyEstimate,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
key: 'rule',
|
||||
label: '规则依据',
|
||||
value: related.ruleLabel
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
@@ -145,11 +271,6 @@ export function buildRelatedApplicationFactItems(request = {}) {
|
||||
value: related.amountLabel,
|
||||
highlight: true,
|
||||
emphasis: true
|
||||
},
|
||||
{
|
||||
key: 'transport_mode',
|
||||
label: '出行方式',
|
||||
value: related.transportMode
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ export function buildMockApplicationTransportEstimate({
|
||||
simulatedLatencyMs,
|
||||
source: 'mock_ticket_price_query_v1',
|
||||
confidence: 'mock',
|
||||
basisText: `已查询 ${queryLabel} ${mode}参考票价,按${bandLabel}往返 ${amountDisplay}元估算(查询耗时 ${simulatedLatencyMs}ms)`
|
||||
basisText: `预估交通费用 ${amountDisplay}元`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,6 +99,19 @@ export function resolveExpenseTypeCode(ontology) {
|
||||
return String(entity?.normalized_value || entity?.value || 'other').trim() || 'other'
|
||||
}
|
||||
|
||||
function looksLikeStructuredTravelApplication(prompt) {
|
||||
const text = String(prompt || '')
|
||||
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(text)
|
||||
&& /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(text)
|
||||
&& /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(text)
|
||||
}
|
||||
|
||||
function resolveApplicationExpenseTypeCode(ontology, prompt) {
|
||||
const code = resolveExpenseTypeCode(ontology)
|
||||
if (code !== 'other') return code
|
||||
return looksLikeStructuredTravelApplication(prompt) ? 'travel' : code
|
||||
}
|
||||
|
||||
export function resolveExpenseTypeLabel(code) {
|
||||
return EXPENSE_TYPE_LABELS[String(code || '').trim()] || EXPENSE_TYPE_LABELS.other
|
||||
}
|
||||
@@ -358,7 +371,7 @@ export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
|
||||
}
|
||||
|
||||
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
|
||||
const expenseTypeCode = resolveExpenseTypeCode(ontology)
|
||||
const expenseTypeCode = resolveApplicationExpenseTypeCode(ontology, prompt)
|
||||
const amount = resolveApplicationAmount(ontology)
|
||||
const documentTypeEntity = resolveEntity(ontology, 'document_type')
|
||||
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
|
||||
|
||||
@@ -31,11 +31,11 @@ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||||
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
||||
|
||||
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
||||
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核'
|
||||
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
|
||||
|
||||
export function resolveApplicationTimeLabel(applicationType = '') {
|
||||
const label = String(applicationType || '').trim()
|
||||
if (/差旅|出差/.test(label)) return '行程时间'
|
||||
if (/差旅|出差/.test(label)) return '出发时间'
|
||||
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
|
||||
return '申请时间'
|
||||
}
|
||||
@@ -47,10 +47,36 @@ function resolveApplicationFieldLabel(item, fields = {}) {
|
||||
return item.label
|
||||
}
|
||||
|
||||
function isTravelApplicationType(applicationType = '') {
|
||||
return /差旅|出差/.test(String(applicationType || '').trim())
|
||||
}
|
||||
|
||||
function resolveApplicationTripDateParts(fields = {}) {
|
||||
const timeText = String(fields.time || '').trim()
|
||||
const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || timeText)
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, fields.days)
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate: inferredEndDate || explicitEndDate || startDate
|
||||
}
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function looksLikeStructuredTravelApplication(text) {
|
||||
const source = String(text || '')
|
||||
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(source)
|
||||
&& /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(source)
|
||||
&& /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
|
||||
}
|
||||
|
||||
function resolveFirstMatch(text, patterns = []) {
|
||||
for (const pattern of patterns) {
|
||||
const match = text.match(pattern)
|
||||
@@ -106,6 +132,7 @@ function resolvePreviewToday(options = {}) {
|
||||
|
||||
function resolveApplicationType(text) {
|
||||
const compact = compactText(text)
|
||||
if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
|
||||
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
|
||||
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
|
||||
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
|
||||
@@ -224,7 +251,7 @@ function buildTransportPolicyText(transportMode, location = '', transportEstimat
|
||||
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
||||
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
return `${estimate.basisText},报销阶段按真实票据复核`
|
||||
return estimate.basisText
|
||||
}
|
||||
|
||||
function ensureApplicationPolicyFields(fields = {}) {
|
||||
@@ -437,9 +464,8 @@ export function applyApplicationPolicyEstimateResult(preview = {}, result = {},
|
||||
allowanceAmount: result?.allowance_amount
|
||||
})
|
||||
const transportEstimate = systemEstimate.transportEstimate
|
||||
const queryLabel = transportEstimate?.queryDate || '出行日期待确认'
|
||||
const transportText = transportEstimate
|
||||
? `交通 ${systemEstimate.transportAmountDisplay}元(按 ${queryLabel} 参考票价) + `
|
||||
? `交通 ${systemEstimate.transportAmountDisplay}元 + `
|
||||
: ''
|
||||
const totalAmount = systemEstimate.totalAmountDisplay
|
||||
const amount = totalAmount ? `${totalAmount}元` : fields.amount
|
||||
@@ -499,7 +525,6 @@ export function refreshApplicationPreviewTransportEstimate(preview = {}) {
|
||||
const hotelAmount = formatPolicyMoney(hotelAmountSource)
|
||||
const allowanceAmount = formatPolicyMoney(allowanceAmountSource)
|
||||
const hasPolicyAmounts = parseMoneyNumber(hotelAmountSource) > 0 || parseMoneyNumber(allowanceAmountSource) > 0
|
||||
const queryLabel = transportEstimate.queryDate || '出行日期待确认'
|
||||
const nextFields = {
|
||||
...fields,
|
||||
transportPolicy: buildTransportPolicyText(fields.transportMode, location, transportEstimate, fields.time),
|
||||
@@ -513,7 +538,7 @@ export function refreshApplicationPreviewTransportEstimate(preview = {}) {
|
||||
if (hasPolicyAmounts) {
|
||||
const days = Number(policyResult.days) || parseApplicationDaysValue(fields.days) || 1
|
||||
const totalAmount = systemEstimate.totalAmountDisplay
|
||||
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}元(按 ${queryLabel} 参考票价) + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
|
||||
nextFields.policyEstimate = `交通 ${systemEstimate.transportAmountDisplay}元 + 住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天)`
|
||||
nextFields.amount = totalAmount ? `${totalAmount}元` : nextFields.amount
|
||||
nextFields.policyTotalAmount = totalAmount ? `${totalAmount}元` : ''
|
||||
}
|
||||
@@ -639,17 +664,41 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
|
||||
export function buildApplicationPreviewRows(preview = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.map((item) => {
|
||||
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.flatMap((item) => {
|
||||
if (item.key === 'time' && isTravelApplicationType(fields.applicationType)) {
|
||||
const tripDates = resolveApplicationTripDateParts(fields)
|
||||
const rawValue = fields[item.key]
|
||||
const missing = item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
||||
return [
|
||||
{
|
||||
...item,
|
||||
label: '出发时间',
|
||||
value: tripDates.startDate || '待补充',
|
||||
editable: item.editable !== false,
|
||||
highlight: Boolean(item.highlight),
|
||||
missing
|
||||
},
|
||||
{
|
||||
key: 'time_return',
|
||||
label: '返回时间',
|
||||
value: tripDates.endDate || '待补充',
|
||||
editable: false,
|
||||
highlight: Boolean(item.highlight),
|
||||
missing
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const rawValue = fields[item.key]
|
||||
const value = String(rawValue || '').trim() || '待补充'
|
||||
return {
|
||||
return [{
|
||||
...item,
|
||||
label: resolveApplicationFieldLabel(item, fields),
|
||||
value,
|
||||
editable: item.editable !== false,
|
||||
highlight: Boolean(item.highlight),
|
||||
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,152 +1,150 @@
|
||||
<template>
|
||||
<main class="login-page">
|
||||
<section class="login-visual" aria-label="智能费用管理运营能力">
|
||||
<div class="visual-brand">
|
||||
<LogoMark />
|
||||
<strong>{{ displayCompanyName }}</strong>
|
||||
<header class="page-brand">
|
||||
<LogoMark />
|
||||
<strong>{{ displayCompanyName }}</strong>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<p class="eyebrow-text">Smart Expense Operations</p>
|
||||
<h1>企业报销智能运营台</h1>
|
||||
<p class="hero-lead">让报销审批更智能、更高效</p>
|
||||
<p class="hero-sub">智能审单 · 自动化审批 · 风险预警 · SLA 监控 · 数据驱动决策</p>
|
||||
|
||||
<div class="hero-stage" aria-hidden="true">
|
||||
<span class="flow-line flow-a"></span>
|
||||
<span class="flow-line flow-b"></span>
|
||||
<span class="flow-line flow-c"></span>
|
||||
|
||||
<div class="metric-card amount">
|
||||
<span>报销金额趋势</span>
|
||||
<strong>¥ 61,600</strong>
|
||||
<small>较昨日 <b class="up">+8.3%</b></small>
|
||||
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
|
||||
</div>
|
||||
|
||||
<div class="document-card">
|
||||
<span>报销单</span>
|
||||
<i></i><i></i><i></i>
|
||||
<b class="doc-check"><i class="mdi mdi-check"></i></b>
|
||||
</div>
|
||||
|
||||
<img class="shield-art" src="../assets/security-shield.png" alt="" />
|
||||
|
||||
<div class="round-badge ai">AI</div>
|
||||
|
||||
<div class="metric-card risk">
|
||||
<span>风险预警</span>
|
||||
<strong><i class="mdi mdi-alert"></i> 14 单</strong>
|
||||
<small>较昨日 <b class="danger">+16.7%</b></small>
|
||||
</div>
|
||||
|
||||
<div class="metric-card audit">
|
||||
<span>审批效率</span>
|
||||
<strong>78%</strong>
|
||||
<small>较昨日 <b class="up">+6.2%</b></small>
|
||||
</div>
|
||||
|
||||
<div class="metric-card sla">
|
||||
<span>SLA 达成率</span>
|
||||
<strong>96%</strong>
|
||||
<small>较昨日 <b class="up">+3.1%</b></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visual-copy">
|
||||
<p>智能费用管理</p>
|
||||
<h1>让企业财务更高效、更合规、更可控</h1>
|
||||
<span>以智能化流程驱动费用全生命周期管理,助力企业降本增效,稳健前行。</span>
|
||||
</div>
|
||||
|
||||
<div class="visual-feature-list" aria-label="核心能力">
|
||||
<div class="feature-strip" aria-label="核心能力">
|
||||
<article v-for="item in features" :key="item.title">
|
||||
<span class="visual-feature-icon">
|
||||
<ElIcon><component :is="item.icon" /></ElIcon>
|
||||
</span>
|
||||
<span :class="item.tone"><i :class="item.icon"></i></span>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<p>{{ item.desc }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<img class="visual-main-asset" :src="loginMainVisualImage" alt="" aria-hidden="true" />
|
||||
<img class="visual-chart-asset" :src="loginChartPanelsImage" alt="" aria-hidden="true" />
|
||||
|
||||
<footer class="visual-footer">
|
||||
<span>© 2024 智能费用管理平台</span>
|
||||
<i></i>
|
||||
<span>服务热线:400-888-8888</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section class="login-panel" aria-label="登录表单">
|
||||
<div class="login-card">
|
||||
<div class="card-brand">
|
||||
<LogoMark />
|
||||
<strong>{{ displayCompanyName }}</strong>
|
||||
<section class="login-card" aria-label="登录表单">
|
||||
<div class="card-brand">
|
||||
<LogoMark />
|
||||
<strong>{{ displayCompanyName }}</strong>
|
||||
</div>
|
||||
|
||||
<header class="card-head">
|
||||
<h2>欢迎登录</h2>
|
||||
<p>使用员工邮箱或管理员账号进入系统</p>
|
||||
</header>
|
||||
|
||||
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
||||
<label class="field">
|
||||
<span class="sr-only">账号</span>
|
||||
<i class="mdi mdi-account-outline"></i>
|
||||
<input v-model="username" type="text" placeholder="请输入员工邮箱 / 管理员账号" autocomplete="username" required />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="sr-only">密码</span>
|
||||
<i class="mdi mdi-lock-outline"></i>
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入登录密码"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
class="field-icon-btn"
|
||||
type="button"
|
||||
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-off'"></i>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="sr-only">企业或租户</span>
|
||||
<i class="mdi mdi-office-building"></i>
|
||||
<select v-model="tenant" class="tenant-select" aria-label="请选择企业或租户">
|
||||
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
|
||||
</select>
|
||||
<span class="field-select-chevron" aria-hidden="true">
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="form-meta">
|
||||
<label class="remember">
|
||||
<input v-model="remember" type="checkbox" />
|
||||
<span>记住账号</span>
|
||||
</label>
|
||||
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
|
||||
</div>
|
||||
|
||||
<header class="card-head">
|
||||
<h2>欢迎登录</h2>
|
||||
<p>智能费用管理平台</p>
|
||||
</header>
|
||||
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
|
||||
|
||||
<form class="login-form" @submit.prevent="submitLogin">
|
||||
<label class="form-field">
|
||||
<span class="sr-only">账号</span>
|
||||
<ElInput
|
||||
v-model="username"
|
||||
class="login-input"
|
||||
:prefix-icon="User"
|
||||
autocomplete="username"
|
||||
clearable
|
||||
placeholder="请输入账号"
|
||||
/>
|
||||
</label>
|
||||
<button class="submit-btn" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="sr-only">密码</span>
|
||||
<ElInput
|
||||
v-model="password"
|
||||
class="login-input"
|
||||
:prefix-icon="Lock"
|
||||
autocomplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
show-password
|
||||
type="password"
|
||||
/>
|
||||
</label>
|
||||
<div class="divider"><span>或</span></div>
|
||||
|
||||
<label class="form-field">
|
||||
<span class="sr-only">所属企业</span>
|
||||
<ElSelect
|
||||
v-model="tenant"
|
||||
class="login-select"
|
||||
popper-class="login-tenant-popper"
|
||||
placeholder="请选择所属企业"
|
||||
:suffix-icon="OfficeBuilding"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in tenantOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</label>
|
||||
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')">
|
||||
<i class="mdi mdi-shield-outline"></i>
|
||||
<span>SSO 单点登录</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-meta">
|
||||
<ElCheckbox v-model="remember" class="login-checkbox">记住账号</ElCheckbox>
|
||||
<button type="button" class="link-button" @click="emit('recover-password')">忘记密码?</button>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
|
||||
|
||||
<ElButton
|
||||
class="login-submit"
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="submitting"
|
||||
:disabled="submitting"
|
||||
>
|
||||
登录
|
||||
</ElButton>
|
||||
|
||||
<ElButton
|
||||
class="login-sso"
|
||||
:icon="Grid"
|
||||
:disabled="submitting"
|
||||
@click="emit('sso-login')"
|
||||
>
|
||||
SSO 单点登录
|
||||
</ElButton>
|
||||
</form>
|
||||
|
||||
<footer class="security-note">
|
||||
登录即表示您已阅读并同意
|
||||
<button type="button">《用户协议》</button>
|
||||
和
|
||||
<button type="button">《隐私政策》</button>
|
||||
</footer>
|
||||
</div>
|
||||
<footer class="security-note">
|
||||
<i class="mdi mdi-lock-outline"></i>
|
||||
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ElButton } from 'element-plus/es/components/button/index.mjs'
|
||||
import { ElCheckbox } from 'element-plus/es/components/checkbox/index.mjs'
|
||||
import { ElIcon } from 'element-plus/es/components/icon/index.mjs'
|
||||
import { ElInput } from 'element-plus/es/components/input/index.mjs'
|
||||
import { ElOption, ElSelect } from 'element-plus/es/components/select/index.mjs'
|
||||
import {
|
||||
Connection,
|
||||
DataAnalysis,
|
||||
DocumentChecked,
|
||||
Grid,
|
||||
Lock,
|
||||
OfficeBuilding,
|
||||
User
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
import loginChartPanelsImage from '../assets/login-reference-chart-panels.png'
|
||||
import loginMainVisualImage from '../assets/login-reference-main-visual.png'
|
||||
import { useLoginView } from '../composables/useLoginView.js'
|
||||
|
||||
const props = defineProps({
|
||||
@@ -168,32 +166,7 @@ const emit = defineEmits(['login', 'recover-password', 'sso-login'])
|
||||
|
||||
const displayCompanyName = computed(() => props.companyName || '易财费控')
|
||||
|
||||
const {
|
||||
features,
|
||||
LogoMark,
|
||||
password,
|
||||
remember,
|
||||
tenant,
|
||||
tenantOptions,
|
||||
username
|
||||
} = useLoginView()
|
||||
|
||||
const featureIconMap = {
|
||||
recognition: DocumentChecked,
|
||||
workflow: Connection,
|
||||
insight: DataAnalysis
|
||||
}
|
||||
|
||||
features.forEach((item) => {
|
||||
item.icon = featureIconMap[item.iconKey] || DocumentChecked
|
||||
})
|
||||
|
||||
function submitLogin() {
|
||||
emit('login', {
|
||||
username: username.value,
|
||||
password: password.value
|
||||
})
|
||||
}
|
||||
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView()
|
||||
</script>
|
||||
|
||||
<style scoped src="../assets/styles/views/login-view.css"></style>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<div class="content-grid top-grid">
|
||||
<article class="panel dashboard-card trend-panel">
|
||||
<div class="card-head">
|
||||
<h3>报销申请与审批趋势 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<h3>每日报销金额 <i class="mdi mdi-information-outline"></i></h3>
|
||||
<EnterpriseSelect
|
||||
v-model="activeTrendRange"
|
||||
class="card-select"
|
||||
@@ -37,10 +37,23 @@
|
||||
</div>
|
||||
|
||||
<TrendChart
|
||||
mode="amount"
|
||||
:labels="activeTrend.labels"
|
||||
:applications="activeTrend.applications"
|
||||
:approved="activeTrend.approved"
|
||||
:avg-hours="activeTrend.avgHours"
|
||||
:claim-count="activeTrend.claimCount"
|
||||
:claim-amount="activeTrend.claimAmount"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card trend-count-panel">
|
||||
<div class="card-head">
|
||||
<h3>每日报销数量 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<TrendChart
|
||||
mode="count"
|
||||
:labels="activeTrend.labels"
|
||||
:claim-count="activeTrend.claimCount"
|
||||
:claim-amount="activeTrend.claimAmount"
|
||||
/>
|
||||
</article>
|
||||
|
||||
@@ -51,20 +64,12 @@
|
||||
<DonutChart :items="spendLegend" :center-value="spendCenterValue" center-label="费用总额" />
|
||||
<p class="panel-note">* 百分比按当前时间范围内的费用金额计算</p>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card donut-panel">
|
||||
<div class="card-head">
|
||||
<h3>风险异常分布 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
<DonutChart :items="riskLegend" :center-value="`${riskTotal}`" center-label="异常预警单" />
|
||||
<p class="panel-note">* 近 30 天数据</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="content-grid bottom-grid">
|
||||
<article class="panel dashboard-card rank-panel">
|
||||
<div class="card-head">
|
||||
<h3>部门报销排行(待处理金额)<i class="mdi mdi-information-outline"></i></h3>
|
||||
<h3>部门报销排行(费用金额)<i class="mdi mdi-information-outline"></i></h3>
|
||||
<EnterpriseSelect
|
||||
v-model="activeDepartmentRange"
|
||||
class="card-select"
|
||||
@@ -77,33 +82,58 @@
|
||||
<BarChart :items="rankedDepartments" />
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card bottleneck-panel">
|
||||
<article class="panel dashboard-card employee-rank-panel">
|
||||
<div class="card-head">
|
||||
<h3>审批瓶颈(平均处理时长) <i class="mdi mdi-information-outline"></i></h3>
|
||||
<h3>个人报销排行(本月)<i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<div class="bottleneck-list">
|
||||
<BarChart :items="rankedEmployees" />
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card top-claim-panel">
|
||||
<div class="card-head">
|
||||
<h3>本月高额单据 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<div class="top-claim-list">
|
||||
<div
|
||||
v-for="(item, index) in bottlenecks"
|
||||
:key="item.name"
|
||||
class="bottleneck-row"
|
||||
:style="{ '--delay': `${index * 70}ms` }"
|
||||
v-for="item in topClaims"
|
||||
:key="item.claimNo"
|
||||
class="top-claim-row"
|
||||
>
|
||||
<div class="reviewer">
|
||||
<div class="reviewer-avatar">{{ item.avatar }}</div>
|
||||
<div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.role }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ item.claimNo }}</strong>
|
||||
<span>{{ item.employeeName }} · {{ item.departmentName || '未归属部门' }}</span>
|
||||
</div>
|
||||
<div class="reviewer-stats">
|
||||
<strong>{{ item.duration }}</strong>
|
||||
<span class="status-tag" :class="item.tone">{{ item.status }}</span>
|
||||
<div>
|
||||
<strong>{{ item.amountLabel }}</strong>
|
||||
<span>{{ item.expenseTypeLabel }} · {{ item.statusLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<button type="button" class="text-link">查看全部 <i class="mdi mdi-chevron-right"></i></button>
|
||||
<article class="panel dashboard-card budget-metrics-panel">
|
||||
<div class="card-head">
|
||||
<h3>预算指标 <i class="mdi mdi-information-outline"></i></h3>
|
||||
</div>
|
||||
|
||||
<div class="budget-metric-grid">
|
||||
<div
|
||||
v-for="(item, index) in budgetMetrics"
|
||||
:key="item.label"
|
||||
class="budget-metric-item"
|
||||
:class="item.tone"
|
||||
:style="{ '--delay': `${index * 70}ms` }"
|
||||
>
|
||||
<span class="budget-metric-icon"><i :class="item.icon"></i></span>
|
||||
<div>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<em>{{ item.detail }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel dashboard-card budget-panel">
|
||||
@@ -326,7 +356,7 @@ const {
|
||||
activeRiskWindowDays,
|
||||
activeTrend,
|
||||
activeTrendRange,
|
||||
bottlenecks,
|
||||
budgetMetrics,
|
||||
budgetSummary,
|
||||
departmentRangeOptions,
|
||||
digitalEmployeeCategoryRows,
|
||||
@@ -338,16 +368,15 @@ const {
|
||||
digitalEmployeeTaskRanking,
|
||||
kpiMetrics,
|
||||
rankedDepartments,
|
||||
rankedEmployees,
|
||||
riskDashboard,
|
||||
riskDashboardError,
|
||||
riskDashboardLoading,
|
||||
riskDailyTrendRows,
|
||||
riskLegend,
|
||||
riskKpiMetrics,
|
||||
riskLevelLegend,
|
||||
riskSignalRanking,
|
||||
riskSourceLegend,
|
||||
riskTotal,
|
||||
riskWindowOptions,
|
||||
setRiskWindowDays,
|
||||
spendCenterValue,
|
||||
@@ -362,6 +391,7 @@ const {
|
||||
systemUsageDurationRows,
|
||||
systemUsageDurationSummary,
|
||||
systemUserTokenUsage,
|
||||
topClaims,
|
||||
trendRanges
|
||||
} = useOverviewView(props)
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export default {
|
||||
const rows = demoDepartments
|
||||
const max = Math.max(...rows.map((item) => item.amount), 1)
|
||||
|
||||
return rows.slice(0, 5).map((item, index) => ({
|
||||
return rows.slice(0, 6).map((item, index) => ({
|
||||
...item,
|
||||
rank: index + 1,
|
||||
shortName: item.name,
|
||||
|
||||
@@ -83,13 +83,38 @@ function normalizeApplicationDateText(value) {
|
||||
}
|
||||
|
||||
function normalizeApplicationBusinessTime(claim) {
|
||||
const start = normalizeApplicationDateText(claim?.start_date || claim?.startDate || claim?.begin_date || claim?.beginDate)
|
||||
const end = normalizeApplicationDateText(claim?.end_date || claim?.endDate || claim?.finish_date || claim?.finishDate)
|
||||
const detail = resolveApplicationDetailPayload(claim)
|
||||
const start = normalizeApplicationDateText(
|
||||
detail.start_date
|
||||
|| detail.startDate
|
||||
|| detail.departure_date
|
||||
|| detail.departureDate
|
||||
|| claim?.start_date
|
||||
|| claim?.startDate
|
||||
|| claim?.begin_date
|
||||
|| claim?.beginDate
|
||||
)
|
||||
const end = normalizeApplicationDateText(
|
||||
detail.end_date
|
||||
|| detail.endDate
|
||||
|| detail.return_date
|
||||
|| detail.returnDate
|
||||
|| claim?.end_date
|
||||
|| claim?.endDate
|
||||
|| claim?.finish_date
|
||||
|| claim?.finishDate
|
||||
)
|
||||
if (start && end && start !== end) {
|
||||
return `${start} 至 ${end}`
|
||||
}
|
||||
return normalizeApplicationDateText(
|
||||
start
|
||||
|| detail.application_business_time
|
||||
|| detail.applicationBusinessTime
|
||||
|| detail.business_time
|
||||
|| detail.businessTime
|
||||
|| detail.time_range
|
||||
|| detail.timeRange
|
||||
|| claim?.business_time
|
||||
|| claim?.businessTime
|
||||
|| claim?.time_range
|
||||
@@ -101,6 +126,21 @@ function normalizeApplicationBusinessTime(claim) {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplicationDetailPayload(claim) {
|
||||
const flags = Array.isArray(claim?.risk_flags_json)
|
||||
? claim.risk_flags_json
|
||||
: Array.isArray(claim?.riskFlags)
|
||||
? claim.riskFlags
|
||||
: []
|
||||
const detailFlag = flags.find((flag) => (
|
||||
flag &&
|
||||
typeof flag === 'object' &&
|
||||
normalizeLower(flag.source) === 'application_detail'
|
||||
))
|
||||
const detail = detailFlag?.application_detail || detailFlag?.applicationDetail || {}
|
||||
return detail && typeof detail === 'object' ? detail : {}
|
||||
}
|
||||
|
||||
function toTimestamp(value) {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
|
||||
@@ -231,20 +271,51 @@ export function isUsableRequiredApplicationClaim(claim) {
|
||||
}
|
||||
|
||||
export function normalizeRequiredApplicationCandidate(claim) {
|
||||
const detail = resolveApplicationDetailPayload(claim)
|
||||
const claimNo = normalizeText(claim?.claim_no || claim?.claimNo)
|
||||
const location = normalizeText(claim?.location || claim?.business_location || claim?.businessLocation)
|
||||
const amountText = formatAmount(claim?.amount || claim?.budget_amount || claim?.budgetAmount)
|
||||
const location = normalizeText(
|
||||
detail.location
|
||||
|| detail.application_location
|
||||
|| claim?.location
|
||||
|| claim?.business_location
|
||||
|| claim?.businessLocation
|
||||
)
|
||||
const amount = normalizeText(
|
||||
detail.amount
|
||||
|| detail.application_amount
|
||||
|| claim?.amount
|
||||
|| claim?.budget_amount
|
||||
|| claim?.budgetAmount
|
||||
)
|
||||
const amountText = formatAmount(amount)
|
||||
const status = normalizeApplicationStatus(claim)
|
||||
|
||||
return {
|
||||
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
|
||||
claim_no: claimNo,
|
||||
expense_type: normalizeExpenseType(claim),
|
||||
reason: normalizeText(claim?.reason || claim?.business_reason || claim?.description || claim?.title),
|
||||
reason: normalizeText(detail.reason || detail.application_reason || claim?.reason || claim?.business_reason || claim?.description || claim?.title),
|
||||
location,
|
||||
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
|
||||
amount,
|
||||
amount_label: amountText,
|
||||
business_time: normalizeApplicationBusinessTime(claim),
|
||||
business_time: normalizeText(
|
||||
detail.application_business_time
|
||||
|| detail.applicationBusinessTime
|
||||
|| detail.business_time
|
||||
|| detail.businessTime
|
||||
|| detail.time_range
|
||||
|| detail.timeRange
|
||||
|| detail.time
|
||||
|| detail.application_time
|
||||
) || normalizeApplicationBusinessTime(claim),
|
||||
days: normalizeText(detail.days || detail.application_days),
|
||||
transport_mode: normalizeText(detail.transport_mode || detail.application_transport_mode),
|
||||
lodging_daily_cap: normalizeText(detail.lodging_daily_cap || detail.application_lodging_daily_cap),
|
||||
subsidy_daily_cap: normalizeText(detail.subsidy_daily_cap || detail.application_subsidy_daily_cap),
|
||||
transport_policy: normalizeText(detail.transport_policy || detail.application_transport_policy),
|
||||
policy_estimate: normalizeText(detail.policy_estimate || detail.application_policy_estimate),
|
||||
rule_name: normalizeText(detail.rule_name || detail.application_rule_name),
|
||||
rule_version: normalizeText(detail.rule_version || detail.application_rule_version),
|
||||
status,
|
||||
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
|
||||
application_date: normalizeApplicationDate(claim)
|
||||
@@ -296,6 +367,14 @@ export function buildRequiredApplicationActions(applications, actionType) {
|
||||
application_amount: application.amount,
|
||||
application_amount_label: application.amount_label,
|
||||
application_business_time: application.business_time,
|
||||
application_days: application.days,
|
||||
application_transport_mode: application.transport_mode,
|
||||
application_lodging_daily_cap: application.lodging_daily_cap,
|
||||
application_subsidy_daily_cap: application.subsidy_daily_cap,
|
||||
application_transport_policy: application.transport_policy,
|
||||
application_policy_estimate: application.policy_estimate,
|
||||
application_rule_name: application.rule_name,
|
||||
application_rule_version: application.rule_version,
|
||||
application_status: application.status,
|
||||
application_status_label: application.status_label,
|
||||
application_date: application.application_date
|
||||
|
||||
@@ -140,6 +140,14 @@ function normalizeApplicationCandidates(applications) {
|
||||
amount: normalizeText(item.amount || item.application_amount),
|
||||
amount_label: normalizeText(item.amount_label || item.application_amount_label),
|
||||
business_time: normalizeText(item.business_time || item.application_business_time),
|
||||
days: normalizeText(item.days || item.application_days),
|
||||
transport_mode: normalizeText(item.transport_mode || item.application_transport_mode),
|
||||
lodging_daily_cap: normalizeText(item.lodging_daily_cap || item.application_lodging_daily_cap),
|
||||
subsidy_daily_cap: normalizeText(item.subsidy_daily_cap || item.application_subsidy_daily_cap),
|
||||
transport_policy: normalizeText(item.transport_policy || item.application_transport_policy),
|
||||
policy_estimate: normalizeText(item.policy_estimate || item.application_policy_estimate),
|
||||
rule_name: normalizeText(item.rule_name || item.application_rule_name),
|
||||
rule_version: normalizeText(item.rule_version || item.application_rule_version),
|
||||
status: normalizeText(item.status || item.application_status),
|
||||
status_label: normalizeText(item.status_label || item.application_status_label),
|
||||
application_date: normalizeText(item.application_date)
|
||||
@@ -264,6 +272,14 @@ export function selectGuidedRequiredApplication(state, application = {}) {
|
||||
application_amount: application.application_amount || application.amount || '',
|
||||
application_amount_label: application.application_amount_label || application.amount_label || '',
|
||||
application_business_time: application.application_business_time || application.business_time || '',
|
||||
application_days: application.application_days || application.days || '',
|
||||
application_transport_mode: application.application_transport_mode || application.transport_mode || '',
|
||||
application_lodging_daily_cap: application.application_lodging_daily_cap || application.lodging_daily_cap || '',
|
||||
application_subsidy_daily_cap: application.application_subsidy_daily_cap || application.subsidy_daily_cap || '',
|
||||
application_transport_policy: application.application_transport_policy || application.transport_policy || '',
|
||||
application_policy_estimate: application.application_policy_estimate || application.policy_estimate || '',
|
||||
application_rule_name: application.application_rule_name || application.rule_name || '',
|
||||
application_rule_version: application.application_rule_version || application.rule_version || '',
|
||||
application_status_label: application.application_status_label || application.status_label || '',
|
||||
application_date: application.application_date || ''
|
||||
}),
|
||||
@@ -412,6 +428,7 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
const applicationLocation = values.application_location || ''
|
||||
const applicationAmount = values.application_amount || values.application_amount_label || ''
|
||||
const applicationBusinessTime = values.application_business_time || ''
|
||||
const applicationTransportMode = values.application_transport_mode || ''
|
||||
const fieldLines = []
|
||||
if (linkedApplication) {
|
||||
const applicationParts = buildApplicationSummaryParts(values)
|
||||
@@ -440,6 +457,7 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
business_location: values.location || applicationLocation || '',
|
||||
time_range: values.time_range || applicationBusinessTime || '',
|
||||
business_time: values.time_range || applicationBusinessTime || '',
|
||||
transport_mode: values.transport_mode || applicationTransportMode || '',
|
||||
amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
|
||||
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
|
||||
application_claim_id: values.application_claim_id || '',
|
||||
@@ -449,6 +467,14 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
|
||||
application_amount: values.application_amount || '',
|
||||
application_amount_label: values.application_amount_label || '',
|
||||
application_business_time: values.application_business_time || '',
|
||||
application_days: values.application_days || '',
|
||||
application_transport_mode: values.application_transport_mode || '',
|
||||
application_lodging_daily_cap: values.application_lodging_daily_cap || '',
|
||||
application_subsidy_daily_cap: values.application_subsidy_daily_cap || '',
|
||||
application_transport_policy: values.application_transport_policy || '',
|
||||
application_policy_estimate: values.application_policy_estimate || '',
|
||||
application_rule_name: values.application_rule_name || '',
|
||||
application_rule_version: values.application_rule_version || '',
|
||||
application_date: values.application_date || ''
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ function buildTransportEstimatePendingPreview(preview = {}) {
|
||||
...preview,
|
||||
fields: {
|
||||
...fields,
|
||||
transportPolicy: '正在查询交通参考票价...',
|
||||
transportPolicy: '正在预估交通费用...',
|
||||
policyEstimate: '正在同步费用测算...',
|
||||
transportEstimatedAmount: '查询中'
|
||||
}
|
||||
|
||||
@@ -288,8 +288,8 @@ export function useTravelReimbursementGuidedFlow({
|
||||
const applicationId = normalizeText(current.values.application_claim_id)
|
||||
const applicationReason = normalizeText(current.values.application_reason)
|
||||
const applicationLocation = normalizeText(current.values.application_location)
|
||||
const applicationAmount = normalizeText(current.values.application_amount || current.values.application_amount_label)
|
||||
const applicationBusinessTime = normalizeText(current.values.application_business_time)
|
||||
const applicationTransportMode = normalizeText(current.values.application_transport_mode)
|
||||
if (!originalMessage || !expenseTypeLabel || !applicationNo) {
|
||||
return null
|
||||
}
|
||||
@@ -326,14 +326,23 @@ export function useTravelReimbursementGuidedFlow({
|
||||
business_location: applicationLocation,
|
||||
time_range: applicationBusinessTime,
|
||||
business_time: applicationBusinessTime,
|
||||
amount: applicationAmount,
|
||||
transport_mode: applicationTransportMode,
|
||||
amount: '',
|
||||
application_claim_id: applicationId,
|
||||
application_claim_no: applicationNo,
|
||||
application_reason: applicationReason,
|
||||
application_location: applicationLocation,
|
||||
application_amount: current.values.application_amount || '',
|
||||
application_amount_label: current.values.application_amount_label || '',
|
||||
application_business_time: applicationBusinessTime
|
||||
application_business_time: applicationBusinessTime,
|
||||
application_days: current.values.application_days || '',
|
||||
application_transport_mode: current.values.application_transport_mode || '',
|
||||
application_lodging_daily_cap: current.values.application_lodging_daily_cap || '',
|
||||
application_subsidy_daily_cap: current.values.application_subsidy_daily_cap || '',
|
||||
application_transport_policy: current.values.application_transport_policy || '',
|
||||
application_policy_estimate: current.values.application_policy_estimate || '',
|
||||
application_rule_name: current.values.application_rule_name || '',
|
||||
application_rule_version: current.values.application_rule_version || ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +164,10 @@ test('application preview renders ordered editable rows and submit text uses edi
|
||||
const rows = buildApplicationPreviewRows(editedPreview)
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.label),
|
||||
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '行程时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
|
||||
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '出发时间', '返回时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
|
||||
)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /行程时间:2026-05-25 至 2026-05-28/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /出发时间:2026-05-25/)
|
||||
assert.match(buildApplicationPreviewSubmitText(editedPreview), /返回时间:2026-05-28/)
|
||||
assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/)
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
|
||||
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
|
||||
@@ -212,7 +213,7 @@ test('application estimate builds deterministic mock transport amount and total'
|
||||
assert.equal(datedTrainEstimate.queryDate, '2026-05-25')
|
||||
assert.equal(datedTrainEstimate.amountDisplay, '1,100')
|
||||
assert.equal(datedTrainEstimate.source, 'mock_ticket_price_query_v1')
|
||||
assert.match(datedTrainEstimate.basisText, /查询耗时 \d+ms/)
|
||||
assert.equal(datedTrainEstimate.basisText, '预估交通费用 1,100元')
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs >= 360)
|
||||
assert.ok(datedTrainEstimate.simulatedLatencyMs <= 779)
|
||||
assert.equal(resolveMockApplicationTransportWaitMs(datedTrainEstimate), 320)
|
||||
@@ -247,16 +248,43 @@ test('application preview uses selected date range and business-specific time la
|
||||
const rows = buildApplicationPreviewRows(preview)
|
||||
const submitText = buildApplicationPreviewSubmitText(preview)
|
||||
|
||||
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '行程时间')
|
||||
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '出发时间')
|
||||
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||
assert.equal(preview.fields.days, '4天')
|
||||
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
|
||||
assert.equal(rows.find((row) => row.key === 'time')?.label, '行程时间')
|
||||
assert.match(submitText, /行程时间:2026-02-20 至 2026-02-23/)
|
||||
assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间')
|
||||
assert.equal(rows.find((row) => row.key === 'time')?.value, '2026-02-20')
|
||||
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
|
||||
assert.equal(rows.find((row) => row.key === 'time_return')?.value, '2026-02-23')
|
||||
assert.match(submitText, /出发时间:2026-02-20/)
|
||||
assert.match(submitText, /返回时间:2026-02-23/)
|
||||
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
|
||||
assert.doesNotMatch(submitText, /发生时间:/)
|
||||
})
|
||||
|
||||
test('application preview keeps labeled reason in structured travel form', () => {
|
||||
const preview = buildLocalApplicationPreview([
|
||||
'发生时间:2026-02-20 至 2026-02-23',
|
||||
'地点:上海',
|
||||
'事由:支撑国网仿生产环境建设',
|
||||
'天数:4天'
|
||||
].join('\n'), {
|
||||
name: '曹笑竹',
|
||||
grade: 'P5'
|
||||
})
|
||||
const rows = buildApplicationPreviewRows(preview)
|
||||
|
||||
assert.equal(preview.fields.applicationType, '差旅费用申请')
|
||||
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.fields.reason, '支撑国网仿生产环境建设')
|
||||
assert.equal(preview.fields.days, '4天')
|
||||
assert.equal(rows.find((row) => row.key === 'reason')?.value, '支撑国网仿生产环境建设')
|
||||
assert.equal(rows.find((row) => row.key === 'reason')?.missing, false)
|
||||
assert.equal(rows.find((row) => row.key === 'time')?.label, '出发时间')
|
||||
assert.equal(rows.find((row) => row.key === 'time_return')?.label, '返回时间')
|
||||
})
|
||||
|
||||
test('application preview cleans empty time labels and keeps only business reason', () => {
|
||||
const preview = buildLocalApplicationPreview('发生时间:,去九江出差3天,服务美团业务部署,预计费用1800元,火车', {
|
||||
name: '李文静',
|
||||
@@ -622,7 +650,7 @@ test('application duplicate confirmation flow marks submit step as blocked dupli
|
||||
status: 'succeeded',
|
||||
result: {
|
||||
answer: [
|
||||
'检测到同一申请人、同一申请类型、同一行程时间已存在申请单,系统没有重复创建。',
|
||||
'检测到同一申请人、同一申请类型、同一出发时间已存在申请单,系统没有重复创建。',
|
||||
'已有申请单号:AP-20260602010101-ABCDEFGH',
|
||||
'当前节点:直属领导审批'
|
||||
].join('\n')
|
||||
@@ -679,9 +707,8 @@ test('application preview merges rule center travel estimate into highlighted ro
|
||||
|
||||
assert.equal(estimatedPreview.fields.lodgingDailyCap, '600元/天')
|
||||
assert.equal(estimatedPreview.fields.subsidyDailyCap, '120元/天')
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /参考票价/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /2026-05-25/)
|
||||
assert.match(estimatedPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||||
assert.equal(estimatedPreview.fields.transportPolicy, '预估交通费用 1,100元')
|
||||
assert.doesNotMatch(estimatedPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /交通 1,100元/)
|
||||
assert.match(estimatedPreview.fields.policyEstimate, /3,260元/)
|
||||
assert.equal(estimatedPreview.fields.transportEstimatedAmount, '1,100元')
|
||||
@@ -734,8 +761,8 @@ test('application preview editor refreshes transport estimate after mode change'
|
||||
assert.equal(message.applicationPreview.fields.transportMode, '飞机')
|
||||
assert.equal(message.applicationPreview.fields.transportEstimatedAmount, '2,330元')
|
||||
assert.equal(message.applicationPreview.fields.amount, '4,490元')
|
||||
assert.match(message.applicationPreview.fields.transportPolicy, /已查询 2026-05-25 飞机参考票价/)
|
||||
assert.match(message.applicationPreview.fields.transportPolicy, /查询耗时 \d+ms/)
|
||||
assert.equal(message.applicationPreview.fields.transportPolicy, '预估交通费用 2,330元')
|
||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /参考票价|查询耗时|2026-05-25|真实票据/)
|
||||
assert.doesNotMatch(message.applicationPreview.fields.transportPolicy, /模拟/)
|
||||
assert.ok(persistCount >= 2)
|
||||
assert.equal(toastMessages.at(-1), '已更新出行方式和费用测算。')
|
||||
|
||||
@@ -30,6 +30,8 @@ test('expense application fields use labeled reason and filter resolved missing
|
||||
)
|
||||
|
||||
assert.equal(fields.timeRange, '2026-05-25 至 2026-05-27')
|
||||
assert.equal(fields.expenseTypeCode, 'travel')
|
||||
assert.equal(fields.expenseTypeLabel, '差旅费')
|
||||
assert.equal(fields.location, '上海')
|
||||
assert.equal(fields.reason, '支撑国网服务器部署')
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -2,6 +2,10 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { mapExpenseClaimToRequest } from '../src/composables/useRequests.js'
|
||||
import {
|
||||
buildApplicationDetailFactItems,
|
||||
buildRelatedApplicationFactItems
|
||||
} from '../src/utils/expenseApplicationDetail.js'
|
||||
|
||||
const CREATE_APPLICATION = '\u521b\u5efa\u7533\u8bf7'
|
||||
const DIRECT_MANAGER_APPROVAL = '\u76f4\u5c5e\u9886\u5bfc\u5ba1\u6279'
|
||||
@@ -110,6 +114,55 @@ test('application claims are mapped as application documents', () => {
|
||||
assert.equal(request.progressSteps.find((step) => step.label === WAIT_LEADER_LI_APPROVAL)?.current, true)
|
||||
})
|
||||
|
||||
test('travel application detail splits trip time into departure and return rows', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-trip-time',
|
||||
claim_no: 'AP-20260602103045-TRIPTIME',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
manager_name: 'Leader Li',
|
||||
expense_type: 'travel_application',
|
||||
reason: '支撑国网仿生产环境部署',
|
||||
location: '上海',
|
||||
amount: 3000,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-02-20T00:00:00.000Z',
|
||||
submitted_at: '2026-02-20T02:00:00.000Z',
|
||||
created_at: '2026-02-20T01:30:00.000Z',
|
||||
updated_at: '2026-02-20T02:00:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: '直属领导审批',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'application_detail',
|
||||
application_detail: {
|
||||
application_type: '差旅费用申请',
|
||||
time: '2026-02-20 至 2026-02-23',
|
||||
location: '上海',
|
||||
reason: '支撑国网仿生产环境部署',
|
||||
days: '4 天',
|
||||
transport_mode: '火车',
|
||||
amount: '3000'
|
||||
}
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const factItems = buildApplicationDetailFactItems(request)
|
||||
assert.deepEqual(
|
||||
factItems
|
||||
.filter((item) => ['trip_start_time', 'trip_return_time'].includes(item.key))
|
||||
.map((item) => [item.label, item.value]),
|
||||
[
|
||||
['出发时间', '2026-02-20'],
|
||||
['返回时间', '2026-02-23']
|
||||
]
|
||||
)
|
||||
assert.equal(factItems.some((item) => item.label === '发生时间'), false)
|
||||
assert.equal(factItems.some((item) => item.label === '行程时间'), false)
|
||||
})
|
||||
|
||||
test('application claims wait for department P8 budget monitor after leader approval', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-application-budget',
|
||||
@@ -679,11 +732,17 @@ test('paid reimbursement marks payment progress step as complete', () => {
|
||||
application_type: '差旅费用申请',
|
||||
application_content: '差旅费用申请 / 北京',
|
||||
application_reason: '支撑国网仿生产环境部署',
|
||||
application_days: '3 天',
|
||||
application_days: '4 天',
|
||||
application_location: '北京',
|
||||
application_amount: '3000',
|
||||
application_time: '2026-05-20T00:00:00.000Z',
|
||||
application_transport_mode: '高铁'
|
||||
application_time: '2026-05-20 至 2026-05-23',
|
||||
application_transport_mode: '高铁',
|
||||
application_lodging_daily_cap: '600元/天',
|
||||
application_subsidy_daily_cap: '120元/天',
|
||||
application_transport_policy: '按真实票据复核',
|
||||
application_policy_estimate: '交通按真实票据 + 住宿 2,400元 + 补贴 480元',
|
||||
application_rule_name: '差旅标准规则',
|
||||
application_rule_version: '2026.05'
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -709,8 +768,36 @@ test('paid reimbursement marks payment progress step as complete', () => {
|
||||
assert.equal(linkedStep.time, '已关联 APP-20260520-001')
|
||||
assert.equal(request.relatedApplication.claimNo, 'APP-20260520-001')
|
||||
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
|
||||
assert.equal(request.relatedApplication.days, '3 天')
|
||||
assert.equal(request.relatedApplication.days, '4 天')
|
||||
assert.equal(request.relatedApplication.time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(request.relatedApplication.tripStartDate, '2026-05-20')
|
||||
assert.equal(request.relatedApplication.tripEndDate, '2026-05-23')
|
||||
assert.equal(request.relatedApplication.transportMode, '高铁')
|
||||
assert.equal(request.relatedApplication.lodgingDailyCap, '600元/天')
|
||||
assert.equal(request.relatedApplication.subsidyDailyCap, '120元/天')
|
||||
assert.equal(request.relatedApplication.transportPolicy, '按真实票据复核')
|
||||
assert.equal(request.relatedApplication.policyEstimate, '交通按真实票据 + 住宿 2,400元 + 补贴 480元')
|
||||
assert.equal(request.relatedApplication.ruleLabel, '差旅标准规则 / 2026.05')
|
||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||
assert.deepEqual(
|
||||
buildRelatedApplicationFactItems(request).map((item) => [item.label, item.value]),
|
||||
[
|
||||
['关联单据单号', 'APP-20260520-001'],
|
||||
['申请内容', '差旅费用申请 / 北京'],
|
||||
['出发时间', '2026-05-20'],
|
||||
['返回时间', '2026-05-23'],
|
||||
['申请天数', '4 天'],
|
||||
['申请事由', '支撑国网仿生产环境部署'],
|
||||
['申请地点', '北京'],
|
||||
['出行方式', '高铁'],
|
||||
['住宿上限/天', '600元/天'],
|
||||
['补贴标准/天', '120元/天'],
|
||||
['交通费用口径', '按真实票据复核'],
|
||||
['规则测算参考', '交通按真实票据 + 住宿 2,400元 + 补贴 480元'],
|
||||
['规则依据', '差旅标准规则 / 2026.05'],
|
||||
['预计金额', '¥3,000']
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('reimbursement detail resolves linked application from guided entry context', () => {
|
||||
@@ -739,7 +826,14 @@ test('reimbursement detail resolves linked application from guided entry context
|
||||
application_reason: '支撑国网仿生产环境部署',
|
||||
application_location: '北京',
|
||||
application_amount: '3000',
|
||||
application_amount_label: '¥3,000'
|
||||
application_amount_label: '¥3,000',
|
||||
application_business_time: '2026-05-20 至 2026-05-23',
|
||||
application_days: '4 天',
|
||||
application_transport_mode: '高铁',
|
||||
application_lodging_daily_cap: '600元/天',
|
||||
application_subsidy_daily_cap: '120元/天',
|
||||
application_transport_policy: '按真实票据复核',
|
||||
application_policy_estimate: '交通按真实票据 + 住宿 2,400元 + 补贴 480元'
|
||||
},
|
||||
expense_scene_selection: {
|
||||
application_claim_no: 'AP-202605-001'
|
||||
@@ -752,7 +846,136 @@ test('reimbursement detail resolves linked application from guided entry context
|
||||
assert.equal(request.relatedApplication.claimNo, 'AP-202605-001')
|
||||
assert.equal(request.relatedApplication.reason, '支撑国网仿生产环境部署')
|
||||
assert.equal(request.relatedApplication.location, '北京')
|
||||
assert.equal(request.relatedApplication.time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(request.relatedApplication.tripStartDate, '2026-05-20')
|
||||
assert.equal(request.relatedApplication.tripEndDate, '2026-05-23')
|
||||
assert.equal(request.relatedApplication.days, '4 天')
|
||||
assert.equal(request.relatedApplication.transportMode, '高铁')
|
||||
assert.equal(request.relatedApplication.lodgingDailyCap, '600元/天')
|
||||
assert.equal(request.relatedApplication.subsidyDailyCap, '120元/天')
|
||||
assert.equal(request.relatedApplication.policyEstimate, '交通按真实票据 + 住宿 2,400元 + 补贴 480元')
|
||||
assert.equal(request.relatedApplication.amountLabel, '¥3,000')
|
||||
assert.deepEqual(request.expenseItems, [])
|
||||
})
|
||||
|
||||
test('reimbursement detail hides stale application placeholder and allowance rows before receipts', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-linked-stale-placeholder',
|
||||
claim_no: 'EXP-20260520-010',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
expense_type: 'travel',
|
||||
reason: '支撑国网仿生产环境部署',
|
||||
location: '上海',
|
||||
amount: 3480,
|
||||
invoice_count: 0,
|
||||
occurred_at: '2026-02-20T00:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T02:00:00.000Z',
|
||||
status: 'draft',
|
||||
approval_stage: '待提交',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'application_link',
|
||||
event_type: 'expense_reimbursement_application_linked',
|
||||
review_form_values: {
|
||||
application_claim_id: 'application-guided-stale',
|
||||
application_claim_no: 'AP-202605-010',
|
||||
application_reason: '支撑国网仿生产环境部署',
|
||||
application_location: '上海',
|
||||
application_amount: '3000',
|
||||
application_amount_label: '¥3,000',
|
||||
application_business_time: '2026-02-20 至 2026-02-23',
|
||||
application_days: '4 天',
|
||||
application_transport_mode: '火车'
|
||||
}
|
||||
}
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'placeholder-travel',
|
||||
item_type: 'travel',
|
||||
item_reason: '支撑国网仿生产环境部署',
|
||||
item_location: '上海',
|
||||
item_amount: 3000,
|
||||
item_date: '2026-02-20',
|
||||
invoice_id: ''
|
||||
},
|
||||
{
|
||||
id: 'stale-allowance',
|
||||
item_type: 'travel_allowance',
|
||||
item_reason: '系统自动计算出差补贴:上海市,1天,120.00元/天',
|
||||
item_location: '直辖市/特区',
|
||||
item_amount: 120,
|
||||
item_date: '2026-02-20',
|
||||
invoice_id: ''
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(request.relatedApplication.claimNo, 'AP-202605-010')
|
||||
assert.equal(request.relatedApplication.days, '4 天')
|
||||
assert.equal(request.amount, 0)
|
||||
assert.deepEqual(request.expenseItems, [])
|
||||
assert.equal(request.expenseTableSummary, '暂无费用明细')
|
||||
})
|
||||
|
||||
test('reimbursement detail hides stale allowance when linked application days differ', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-linked-stale-allowance',
|
||||
claim_no: 'EXP-20260520-011',
|
||||
employee_name: '张三',
|
||||
department_name: '交付部',
|
||||
expense_type: 'travel',
|
||||
reason: '支撑国网仿生产环境部署',
|
||||
location: '上海',
|
||||
amount: 474,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-02-20T00:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T02:00:00.000Z',
|
||||
status: 'draft',
|
||||
approval_stage: '待提交',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'application_link',
|
||||
event_type: 'expense_reimbursement_application_linked',
|
||||
application_claim_no: 'AP-202605-011',
|
||||
application_detail: {
|
||||
application_reason: '支撑国网仿生产环境部署',
|
||||
application_location: '上海',
|
||||
application_time: '2026-02-20 至 2026-02-23',
|
||||
application_days: '4 天',
|
||||
application_transport_mode: '火车'
|
||||
}
|
||||
}
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'outbound-train',
|
||||
item_type: 'train_ticket',
|
||||
item_reason: '武汉-上海',
|
||||
item_location: '上海',
|
||||
item_amount: 354,
|
||||
item_date: '2026-02-20',
|
||||
invoice_id: 'ticket-1.png'
|
||||
},
|
||||
{
|
||||
id: 'stale-allowance',
|
||||
item_type: 'travel_allowance',
|
||||
item_reason: '系统自动计算出差补贴:上海市,1天,120.00元/天',
|
||||
item_location: '直辖市/特区',
|
||||
item_amount: 120,
|
||||
item_date: '2026-02-20',
|
||||
invoice_id: ''
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(request.relatedApplication.days, '4 天')
|
||||
assert.deepEqual(request.expenseItems.map((item) => item.id), ['outbound-train'])
|
||||
assert.equal(request.amount, 354)
|
||||
assert.equal(request.expenseTableSummary, '共 1 条费用明细,已关联 1 张票据')
|
||||
})
|
||||
|
||||
test('current direct manager step shows how long the claim has stayed there', () => {
|
||||
|
||||
@@ -232,7 +232,19 @@ test('guided reimbursement requires application selection for travel and enterta
|
||||
amount: 1800,
|
||||
occurred_at: '2026-05-20T08:00:00Z',
|
||||
status: 'approved',
|
||||
created_at: '2026-05-20T08:00:00Z'
|
||||
created_at: '2026-06-02T00:58:00Z',
|
||||
risk_flags_json: [{
|
||||
source: 'application_detail',
|
||||
application_detail: {
|
||||
application_business_time: '2026-05-20 至 2026-05-23',
|
||||
days: '4 天',
|
||||
transport_mode: '火车',
|
||||
lodging_daily_cap: '600元/天',
|
||||
subsidy_daily_cap: '120元/天',
|
||||
transport_policy: '按真实票据复核',
|
||||
policy_estimate: '住宿 2,400元 + 补贴 480元'
|
||||
}
|
||||
}]
|
||||
},
|
||||
{
|
||||
id: 'app-meal',
|
||||
@@ -285,6 +297,11 @@ test('guided reimbursement requires application selection for travel and enterta
|
||||
assert.equal(state.stepKey, 'summary')
|
||||
assert.equal(isGuidedReimbursementReadyForReview(state), true)
|
||||
assert.equal(state.values.application_claim_no, 'AP-202605-001')
|
||||
assert.equal(state.values.application_business_time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(state.values.application_days, '4 天')
|
||||
assert.equal(state.values.application_transport_mode, '火车')
|
||||
assert.equal(state.values.application_lodging_daily_cap, '600元/天')
|
||||
assert.equal(state.values.application_subsidy_daily_cap, '120元/天')
|
||||
const summaryText = buildGuidedReimbursementSummaryText(state)
|
||||
assert.match(summaryText, /关联申请单:AP-202605-001/)
|
||||
assert.match(summaryText, /草稿详情中上传对应票据/)
|
||||
@@ -297,7 +314,12 @@ test('guided reimbursement requires application selection for travel and enterta
|
||||
assert.equal(submitOptions.extraContext.review_form_values.business_location, '上海')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.amount, '')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_amount, '1800')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_business_time, '2026-05-20 至 2026-05-23')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_days, '4 天')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.transport_mode, '火车')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_transport_mode, '火车')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_lodging_daily_cap, '600元/天')
|
||||
assert.equal(submitOptions.extraContext.review_form_values.application_subsidy_daily_cap, '120元/天')
|
||||
assert.equal(submitOptions.extraContext.expense_scene_selection.application_claim_no, 'AP-202605-001')
|
||||
assert.match(submitOptions.rawText, /关联申请单:AP-202605-001/)
|
||||
assert.doesNotMatch(submitOptions.rawText, /事由:待补充/)
|
||||
@@ -377,6 +399,8 @@ test('guided flow is local until final confirmation or collected query handoff',
|
||||
assert.match(guidedFlowScript, /GUIDED_ACTION_SELECT_REQUIRED_APPLICATION/)
|
||||
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(guidedFlowState\.value\)[\s\S]*pushReimbursementSummary\(\)/)
|
||||
assert.match(guidedFlowScript, /isGuidedReimbursementReadyForReview\(currentState\) && fileNames\.length[\s\S]*buildGuidedReviewSubmitOptions\(currentState, mergedFiles\)[\s\S]*skipDraftAssociationPrompt:\s*true[\s\S]*skipUserMessage:\s*true[\s\S]*submitExistingComposer\(submitOptions\)/)
|
||||
assert.doesNotMatch(guidedFlowScript, /amount:\s*applicationAmount/)
|
||||
assert.match(guidedFlowScript, /amount:\s*''/)
|
||||
assert.match(guidedFlowScript, /if \(!applications\.length\) \{[\s\S]*guidedFlowState\.value = createEmptyGuidedFlowState\(\)[\s\S]*meta: \['缺少可关联申请单'\][\s\S]*\}\)/)
|
||||
assert.doesNotMatch(guidedFlowScript, /meta: \['缺少可关联申请单'\],[\s\S]{0,120}suggestedActions: buildGuidedExpenseTypeActions\(\)/)
|
||||
assert.match(guidedFlowScript, /handleSceneSelectionApplicationGate/)
|
||||
|
||||
Reference in New Issue
Block a user