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

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,