Files
X-Financial/web/src/utils/documentCenterViewModel.js
2026-06-22 11:58:53 +08:00

362 lines
13 KiB
JavaScript

import { countClaimRisks, resolveArchiveRiskTone } from './archiveCenterListFilters.js'
import { isNewDocument } from './documentCenterNewState.js'
import { isArchivedDocumentRow } from './documentCenterRows.js'
import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
import {
extractDateText,
formatDocumentListTime,
resolveDocumentSortTime,
resolveDocumentStayTimeDisplay
} from './documentCenterTime.js'
import { normalizeRequestForUi } from './requestViewModel.js'
export const DOCUMENT_TYPE_ALL = 'all'
export const DOCUMENT_TYPE_APPLICATION = 'application'
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
export const SCENE_ALL = 'all'
export const DOCUMENT_SCOPE_ALL = '全部'
export const DOCUMENT_SCOPE_APPLICATION = '申请单'
export const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
export const DOCUMENT_SCOPE_REVIEW = '审核单'
export const DOCUMENT_SCOPE_ARCHIVE = '归档'
export const scopeTabs = [
DOCUMENT_SCOPE_ALL,
DOCUMENT_SCOPE_APPLICATION,
DOCUMENT_SCOPE_REIMBURSEMENT,
DOCUMENT_SCOPE_REVIEW,
DOCUMENT_SCOPE_ARCHIVE
]
export const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
export const DOCUMENT_CENTER_QUERY_KEYS = new Set([
'dc_page',
'dc_page_size',
'dc_scope',
'dc_status',
'dc_doc_type',
'dc_scene',
'dc_q',
'dc_start',
'dc_end'
])
export const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
export const RISK_TONE_META = {
high: { label: '高风险', tone: 'high' },
medium: { label: '中风险', tone: 'medium' },
low: { label: '低风险', tone: 'low' },
none: { label: '无风险', tone: 'none' }
}
export const FILTER_CONFIG_BY_SCOPE = {
[DOCUMENT_SCOPE_ALL]: {
searchPlaceholder: '搜索单号、事项、费用场景...',
sceneFallbackLabel: '单据场景',
dateLabel: '单据时间',
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: true
},
[DOCUMENT_SCOPE_APPLICATION]: {
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
sceneFallbackLabel: '申请场景',
dateLabel: '申请时间',
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_REIMBURSEMENT]: {
searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
sceneFallbackLabel: '费用场景',
dateLabel: '报销时间',
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_REVIEW]: {
searchPlaceholder: '搜索审核单号、事项、当前环节...',
sceneFallbackLabel: '审核场景',
dateLabel: '审核时间',
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: false
},
[DOCUMENT_SCOPE_ARCHIVE]: {
searchPlaceholder: '搜索归档单号、事项、费用场景...',
sceneFallbackLabel: '归档场景',
dateLabel: '归档时间',
statusTitle: '风险等级',
statusTabs: riskLevelTabs,
showDocumentType: false
}
}
export const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
export const pageSizeValues = pageSizeOptions.map((item) => item.value)
export const documentTypeOptions = [
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
]
export function routeQueryEquals(left, right) {
const leftEntries = Object.entries(left || {}).map(([key, value]) => [
key,
Array.isArray(value) ? value.join(',') : String(value ?? '')
])
const rightEntries = Object.entries(right || {}).map(([key, value]) => [
key,
Array.isArray(value) ? value.join(',') : String(value ?? '')
])
if (leftEntries.length !== rightEntries.length) return false
const rightMap = new Map(rightEntries)
return leftEntries.every(([key, value]) => rightMap.get(key) === value)
}
export function buildDocumentRow(request, options = {}) {
const normalized = normalizeRequestForUi(request)
if (!normalized) {
return null
}
const archived = Boolean(options.archived)
const source = options.source || 'owned'
const statusGroup = resolveStatusGroup(normalized, archived)
const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
const riskMeta = buildDocumentRiskMeta(normalized, options.currentUser)
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
const createdSortTime = resolveDocumentSortTime(createdAtSource)
const updatedSortTime = resolveDocumentSortTime(updatedAtSource)
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
const documentTypeLabel =
normalized.documentTypeLabel
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
const initiatorName = String(
normalized.person
|| normalized.employeeName
|| normalized.profileName
|| normalized.applicant
|| request?.employee_name
|| request?.employeeName
|| request?.person
|| ''
).trim() || '待补充'
return {
...normalized,
rawRequest: request,
documentKey: `${source}:${claimId || documentNo}`,
documentTypeCode,
documentTypeLabel,
claimId,
documentNo,
initiatorName,
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
statusGroup,
statusLabel,
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
riskTone: riskMeta.tone,
riskLabel: riskMeta.label,
riskCount: riskMeta.count,
riskTags: riskMeta.tags,
source,
archived,
createdAtDisplay: formatDocumentListTime(createdAtSource),
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
isNewDocument: archived
? false
: isNewDocument({ ...normalized, source, claimId, documentNo }, options.viewedDocumentKeys || []),
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
createdSortTime,
updatedSortTime,
sortTime: Math.max(createdSortTime, updatedSortTime)
}
}
export function buildDocumentRiskMeta(row, currentUser = null) {
const riskFlags = resolveDocumentRiskFlags(row)
const riskSummary = row?.riskSummary || row?.risk
// 列表风险标签按当前查看者可见性过滤,与详情页口径一致。
const viewerOptions = currentUser ? { request: row || {}, currentUser } : null
const count = countClaimRisks(riskFlags, riskSummary, viewerOptions)
if (!count) {
const meta = RISK_TONE_META.none
return {
...meta,
count: 0,
tags: [{ ...meta }]
}
}
const tone = resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions)
const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium
return {
...meta,
count,
tags: [{ tone: meta.tone, label: `${meta.label} ${count}` }]
}
}
export function filterDocumentRows(rows, filters = {}) {
const keyword = String(filters.keyword || '').trim().toLowerCase()
return sortDocumentRowsByLatestTime((rows || []).filter((row) => {
const matchesKeyword = !keyword || [
row.documentNo,
row.documentTypeLabel,
row.typeLabel,
row.initiatorName,
row.reason,
row.node,
row.statusLabel,
row.riskLabel
].filter(Boolean).join('').toLowerCase().includes(keyword)
const matchesDocumentType =
!filters.showDocumentTypeFilter
|| filters.activeDocumentType === DOCUMENT_TYPE_ALL
|| row.documentTypeCode === filters.activeDocumentType
const matchesScene = filters.activeScene === SCENE_ALL || row.typeCode === filters.activeScene
const matchesRiskLevel = matchesRiskLevelTab(row, filters.activeStatusTab, filters.activeScopeTab)
const matchesDateRange = matchesAppliedDateRange(row, filters.appliedStart, filters.appliedEnd)
return matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange
}))
}
export function matchesRiskLevelTab(row, tab, activeScopeTab = DOCUMENT_SCOPE_ALL) {
if (activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
return false
}
if (tab === '全部') return true
if (tab === '高风险') return row.riskTone === 'high'
if (tab === '中风险') return row.riskTone === 'medium'
if (tab === '低风险') return row.riskTone === 'low'
if (tab === '无风险') return row.riskTone === 'none'
return true
}
export function matchesAppliedDateRange(row, start, end) {
if (!start || !end) {
return true
}
const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
return Boolean(date) && date >= start && date <= end
}
export function mergeDocumentRows(rows) {
const rowMap = new Map()
rows.filter(Boolean).forEach((row) => {
const key = row.claimId || row.documentNo || row.documentKey
const current = rowMap.get(key)
if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
rowMap.set(key, row)
}
})
return sortDocumentRowsByLatestTime(Array.from(rowMap.values()))
}
export function hasDocumentCenterActiveFilters(filters = {}) {
return Boolean(
String(filters.listKeyword || '').trim()
|| filters.activeStatusTab !== '全部'
|| (filters.showDocumentTypeFilter && filters.activeDocumentType !== DOCUMENT_TYPE_ALL)
|| filters.activeScene !== SCENE_ALL
|| filters.appliedStart
|| filters.appliedEnd
)
}
export function buildDocumentCenterEmptyState(options = {}) {
const filtered = Boolean(options.hasActiveFilters)
const activeScopeTab = options.activeScopeTab || DOCUMENT_SCOPE_ALL
if (
activeScopeTab === DOCUMENT_SCOPE_APPLICATION
|| options.activeDocumentType === DOCUMENT_TYPE_APPLICATION
) {
return {
eyebrow: '申请单',
title: '当前还没有申请单数据',
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
icon: 'mdi mdi-file-sign-outline',
actionLabel: '',
actionIcon: '',
tone: 'theme',
artLabel: 'APPLY',
tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
}
}
return {
eyebrow: filtered ? '筛选结果为空' : '单据中心',
title: filtered ? '没有符合当前条件的单据' : `${activeScopeTab}”里暂时没有单据`,
desc: filtered
? '可以清空当前分类下的筛选条件后再看看。'
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
actionLabel: '',
actionIcon: '',
tone: 'theme',
artLabel: filtered ? 'FILTER' : 'DOCS',
tips: ['单据中心已接入当前报销单据', '归档视角会同步已归档数据']
}
}
function resolveArchivedDocumentNode(normalized, documentTypeCode) {
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
return '申请归档'
}
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款') {
return '已付款'
}
return normalized.node || normalized.workflowNode || '财务归档'
}
function resolveArchivedStatusLabel(normalized) {
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款' || normalized.node === '已付款') {
return '已付款'
}
return '已归档'
}
function resolveStatusGroup(row, archived) {
if (archived) return 'completed'
if (row.approvalKey === 'draft') return 'draft'
if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
if (row.approvalKey === 'supplement') return 'supplement'
if (row.approvalKey === 'pending_payment') return 'pending_payment'
if (row.approvalKey === 'in_progress') return 'in_progress'
if (row.approvalKey === 'completed') return 'completed'
return 'other'
}
function resolveStatusLabel(row, statusGroup) {
if (statusGroup === 'pending_submit') return '待提交'
if (statusGroup === 'pending_payment') return '待付款'
return row.approval || row.approvalStatus || '处理中'
}
function resolveStatusTone(row, statusGroup) {
if (statusGroup === 'pending_submit') return 'warning'
return row.approvalTone || 'neutral'
}
function resolveDocumentRiskFlags(row) {
if (Array.isArray(row?.riskFlags)) {
return row.riskFlags
}
if (Array.isArray(row?.risk_flags_json)) {
return row.risk_flags_json
}
return []
}
function resolveSourcePriority(row) {
if (row.archived) return 3
if (row.source === 'approval') return 2
return 1
}