feat: 财务看板口径重构与半年模拟数据及报销状态注册表
- 重构 finance_dashboard 口径计算,新增模拟公司画像数据生成与筛选 - 引入 expense_claim_status_registry 统一报销状态流转 - 完善报销草稿流程、Item Sync 与本体解析器 - 优化总览页趋势图、分页组件与请求进度步骤 - 增强报销申请快速预览、本体工具与详情展示 - 新增半年报销模拟数据种子脚本与状态审计工具 - 补充财务看板、报销状态注册与模拟数据测试覆盖
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user