feat: 财务看板口径重构与半年模拟数据及报销状态注册表

- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选
- 引入 expense_claim_status_registry 统一报销状态流转
- 完善报销草稿流程、Item Sync 与本体解析器
- 优化总览页趋势图、分页组件与请求进度步骤
- 增强报销申请快速预览、本体工具与详情展示
- 新增半年报销模拟数据种子脚本与状态审计工具
- 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 16:22:59 +08:00
parent ca691f3ee0
commit 0c74b4ab4a
54 changed files with 6810 additions and 1238 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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,

View File

@@ -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: ''
}
]

View File

@@ -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
}
}

View File

@@ -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
}
]

View File

@@ -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}`
}
}

View File

@@ -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')

View File

@@ -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)
}
}]
})
}

View File

@@ -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>

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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 || ''
}

View File

@@ -50,7 +50,7 @@ function buildTransportEstimatePendingPreview(preview = {}) {
...preview,
fields: {
...fields,
transportPolicy: '正在查询交通参考票价...',
transportPolicy: '正在预估交通费用...',
policyEstimate: '正在同步费用测算...',
transportEstimatedAmount: '查询中'
}

View File

@@ -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 || ''
}
}
}

View File

@@ -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), '已更新出行方式和费用测算。')

View File

@@ -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(

View File

@@ -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', () => {

View File

@@ -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/)