427 lines
11 KiB
JavaScript
427 lines
11 KiB
JavaScript
|
|
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
|
|||
|
|
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
|
|||
|
|
|
|||
|
|
const EXPENSE_TYPE_LABELS = {
|
|||
|
|
travel: '差旅费',
|
|||
|
|
travel_application: '差旅费用申请',
|
|||
|
|
expense_application: '费用申请',
|
|||
|
|
purchase_application: '采购费用申请',
|
|||
|
|
meeting_application: '会务费用申请',
|
|||
|
|
train_ticket: '火车票',
|
|||
|
|
flight_ticket: '机票',
|
|||
|
|
ship_ticket: '轮船票',
|
|||
|
|
ferry_ticket: '轮船票',
|
|||
|
|
hotel_ticket: '住宿票',
|
|||
|
|
ride_ticket: '乘车',
|
|||
|
|
travel_allowance: '出差补贴',
|
|||
|
|
entertainment: '业务招待费',
|
|||
|
|
marketing: '市场推广费',
|
|||
|
|
office: '办公用品费',
|
|||
|
|
meeting: '会务费',
|
|||
|
|
training: '培训费',
|
|||
|
|
software: '软件服务费',
|
|||
|
|
hotel: '住宿费',
|
|||
|
|
transport: '交通费',
|
|||
|
|
meal: '业务招待费',
|
|||
|
|
other: '其他费用'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
|||
|
|
'travel',
|
|||
|
|
'train_ticket',
|
|||
|
|
'flight_ticket',
|
|||
|
|
'hotel_ticket',
|
|||
|
|
'ride_ticket',
|
|||
|
|
'meeting',
|
|||
|
|
'entertainment'
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
|||
|
|
const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
|
|||
|
|
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 = '关联单据'
|
|||
|
|
const APPLICATION_LINK_STATUS_STEP_LABEL = '关联单据状态'
|
|||
|
|
const APPLICATION_ARCHIVE_STAGE_LABEL = '申请归档'
|
|||
|
|
const ARCHIVED_STEP_LABEL = '已归档'
|
|||
|
|
|
|||
|
|
const REIMBURSEMENT_PROGRESS_LABELS = [
|
|||
|
|
RELATED_APPLICATION_STEP_LABEL,
|
|||
|
|
'待提交',
|
|||
|
|
'直属领导审批',
|
|||
|
|
'财务审批',
|
|||
|
|
'待付款',
|
|||
|
|
'已付款',
|
|||
|
|
ARCHIVED_STEP_LABEL
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const APPLICATION_PROGRESS_LABELS = [
|
|||
|
|
'创建申请',
|
|||
|
|
'直属领导审批',
|
|||
|
|
'预算管理者审批',
|
|||
|
|
APPLICATION_LINK_STATUS_STEP_LABEL,
|
|||
|
|
ARCHIVED_STEP_LABEL
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
const APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET = [
|
|||
|
|
'创建申请',
|
|||
|
|
'直属领导审批',
|
|||
|
|
APPLICATION_LINK_STATUS_STEP_LABEL,
|
|||
|
|
ARCHIVED_STEP_LABEL
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
function parseNumber(value) {
|
|||
|
|
const nextValue = Number(value)
|
|||
|
|
return Number.isFinite(nextValue) ? nextValue : 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseOptionalAmount(value) {
|
|||
|
|
if (value === null || value === undefined || String(value).trim() === '') {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
const amount = Number(value)
|
|||
|
|
return Number.isFinite(amount) && amount >= 0 ? amount : null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
function toDate(value) {
|
|||
|
|
if (!value) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const nextDate = new Date(value)
|
|||
|
|
return Number.isNaN(nextDate.getTime()) ? null : nextDate
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDate(value) {
|
|||
|
|
const nextDate = toDate(value)
|
|||
|
|
if (!nextDate) {
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const year = nextDate.getFullYear()
|
|||
|
|
const month = String(nextDate.getMonth() + 1).padStart(2, '0')
|
|||
|
|
const day = String(nextDate.getDate()).padStart(2, '0')
|
|||
|
|
return `${year}-${month}-${day}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDateTime(value) {
|
|||
|
|
const nextDate = toDate(value)
|
|||
|
|
if (!nextDate) {
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const hours = String(nextDate.getHours()).padStart(2, '0')
|
|||
|
|
const minutes = String(nextDate.getMinutes()).padStart(2, '0')
|
|||
|
|
return `${formatDate(nextDate)} ${hours}:${minutes}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatDurationFrom(value, now = Date.now()) {
|
|||
|
|
const startAt = toDate(value)
|
|||
|
|
if (!startAt) {
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const diffMs = Math.max(0, Number(now) - startAt.getTime())
|
|||
|
|
const totalMinutes = Math.floor(diffMs / (60 * 1000))
|
|||
|
|
if (totalMinutes < 1) {
|
|||
|
|
return '刚刚'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const days = Math.floor(totalMinutes / (24 * 60))
|
|||
|
|
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
|||
|
|
const minutes = totalMinutes % 60
|
|||
|
|
|
|||
|
|
if (days > 0) {
|
|||
|
|
return hours > 0 ? `${days}天${hours}小时` : `${days}天`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (hours > 0) {
|
|||
|
|
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return `${minutes}分钟`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatAmount(value) {
|
|||
|
|
return new Intl.NumberFormat('zh-CN', {
|
|||
|
|
style: 'currency',
|
|||
|
|
currency: 'CNY',
|
|||
|
|
minimumFractionDigits: 0,
|
|||
|
|
maximumFractionDigits: Number.isInteger(value) ? 0 : 2
|
|||
|
|
}).format(parseNumber(value))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveTypeLabel(typeCode) {
|
|||
|
|
return EXPENSE_TYPE_LABELS[String(typeCode || '').trim()] || EXPENSE_TYPE_LABELS.other
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveDocumentTypeMeta(claim, typeCode) {
|
|||
|
|
const explicitType = String(
|
|||
|
|
claim?.document_type_code
|
|||
|
|
|| claim?.documentTypeCode
|
|||
|
|
|| claim?.document_type
|
|||
|
|
|| claim?.documentType
|
|||
|
|
|| ''
|
|||
|
|
).trim()
|
|||
|
|
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
|
|||
|
|
const normalizedType = String(typeCode || '').trim()
|
|||
|
|
const isApplication =
|
|||
|
|
explicitType === DOCUMENT_TYPE_APPLICATION
|
|||
|
|
|| explicitType === 'expense_application'
|
|||
|
|
|| isApplicationDocumentNo(claimNo)
|
|||
|
|
|| normalizedType === 'application'
|
|||
|
|
|| normalizedType.endsWith('_application')
|
|||
|
|
|
|||
|
|
return isApplication
|
|||
|
|
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: '申请单' }
|
|||
|
|
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: '报销单' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function normalizeExpenseType(typeCode) {
|
|||
|
|
return String(typeCode || '').trim() || 'other'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveApprovalMeta(status) {
|
|||
|
|
const normalized = String(status || '').trim().toLowerCase()
|
|||
|
|
|
|||
|
|
if (normalized === 'draft') {
|
|||
|
|
return { key: 'draft', label: '草稿', tone: 'draft' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (normalized === 'returned') {
|
|||
|
|
return { key: 'supplement', label: '待提交', tone: 'warning' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (normalized === 'supplement') {
|
|||
|
|
return { key: 'supplement', label: '待补充', tone: 'warning' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (normalized === 'pending_payment') {
|
|||
|
|
return { key: 'pending_payment', label: '待付款', tone: 'warning' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (normalized === 'paid') {
|
|||
|
|
return { key: 'completed', label: '已付款', tone: 'success' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (['approved', 'completed', 'paid'].includes(normalized)) {
|
|||
|
|
return { key: 'completed', label: '已完成', tone: 'success' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (['rejected', 'cancelled'].includes(normalized)) {
|
|||
|
|
return { key: 'rejected', label: '已退回', tone: 'danger' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { key: 'in_progress', label: '审批中', tone: 'info' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false) {
|
|||
|
|
if (String(claim?.status || '').trim().toLowerCase() === 'returned') {
|
|||
|
|
return '待提交'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const rawNode = String(claim?.approval_stage || '').trim()
|
|||
|
|
|
|||
|
|
if (rawNode) {
|
|||
|
|
if (
|
|||
|
|
isApplicationDocument
|
|||
|
|
&& approvalMeta.key === 'completed'
|
|||
|
|
&& (
|
|||
|
|
rawNode === '审批完成'
|
|||
|
|
|| rawNode.includes('审批完成')
|
|||
|
|
|| rawNode.includes('申请完成')
|
|||
|
|
)
|
|||
|
|
) {
|
|||
|
|
return APPLICATION_LINK_STATUS_STEP_LABEL
|
|||
|
|
}
|
|||
|
|
if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
|
|||
|
|
return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
|
|||
|
|
}
|
|||
|
|
if (rawNode === '待补充') {
|
|||
|
|
return '待提交'
|
|||
|
|
}
|
|||
|
|
return rawNode
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' || approvalMeta.key === 'rejected') {
|
|||
|
|
return '待提交'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (approvalMeta.key === 'pending_payment') {
|
|||
|
|
return '待付款'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (approvalMeta.key === 'completed') {
|
|||
|
|
const normalizedStatus = String(claim?.status || '').trim().toLowerCase()
|
|||
|
|
return isApplicationDocument ? APPLICATION_LINK_STATUS_STEP_LABEL : normalizedStatus === 'paid' ? '已付款' : '归档入账'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return '直属领导审批'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stringifyRiskFlag(value) {
|
|||
|
|
if (typeof value === 'string') {
|
|||
|
|
return value.trim()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!value || typeof value !== 'object') {
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for (const key of ['message', 'label', 'reason', 'name']) {
|
|||
|
|
const nextValue = String(value[key] || '').trim()
|
|||
|
|
if (nextValue) {
|
|||
|
|
return nextValue
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const RISK_TONE_LABELS = {
|
|||
|
|
high: '高风险',
|
|||
|
|
medium: '中风险',
|
|||
|
|
low: '低风险'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveHighestRiskTone(flags) {
|
|||
|
|
const tones = flags.map((item) => normalizeRiskFlagTone(item)).filter(Boolean)
|
|||
|
|
if (tones.includes('high')) {
|
|||
|
|
return 'high'
|
|||
|
|
}
|
|||
|
|
if (tones.includes('medium')) {
|
|||
|
|
return 'medium'
|
|||
|
|
}
|
|||
|
|
if (tones.includes('low')) {
|
|||
|
|
return 'low'
|
|||
|
|
}
|
|||
|
|
return 'low'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildRiskMeta(riskFlags) {
|
|||
|
|
if (!Array.isArray(riskFlags) || !riskFlags.length) {
|
|||
|
|
return { summary: '无', tone: 'low', label: '无' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const actionableFlags = filterActionableRiskFlags(riskFlags)
|
|||
|
|
const items = actionableFlags.map((item) => stringifyRiskFlag(item)).filter(Boolean)
|
|||
|
|
if (!items.length) {
|
|||
|
|
return { summary: '无', tone: 'low', label: '无' }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tone = resolveHighestRiskTone(actionableFlags)
|
|||
|
|
return {
|
|||
|
|
summary: items.join(';'),
|
|||
|
|
tone,
|
|||
|
|
label: RISK_TONE_LABELS[tone] || '待关注'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildRiskSummary(riskFlags) {
|
|||
|
|
return buildRiskMeta(riskFlags).summary
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function buildOccurredDisplay(claim) {
|
|||
|
|
const itemDates = Array.isArray(claim?.items)
|
|||
|
|
? claim.items.map((item) => formatDate(item?.item_date)).filter(Boolean)
|
|||
|
|
: []
|
|||
|
|
|
|||
|
|
if (!itemDates.length) {
|
|||
|
|
return formatDate(claim?.occurred_at) || '待补充'
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sortedDates = [...new Set(itemDates)].sort()
|
|||
|
|
if (sortedDates.length === 1) {
|
|||
|
|
return sortedDates[0]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return `${sortedDates[0]} ~ ${sortedDates[sortedDates.length - 1]}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
function normalizeText(value) {
|
|||
|
|
return String(value || '').trim()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isEmailLike(value) {
|
|||
|
|
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resolveDisplayName(...values) {
|
|||
|
|
for (const value of values) {
|
|||
|
|
const normalized = normalizeText(value)
|
|||
|
|
if (normalized && !isEmailLike(normalized)) {
|
|||
|
|
return normalized
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return ''
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
function getRiskFlags(claim) {
|
|||
|
|
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getLatestEvent(events) {
|
|||
|
|
const sortedEvents = events
|
|||
|
|
.filter((item) => item && typeof item === 'object')
|
|||
|
|
.map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
|
|||
|
|
.filter((item) => item.eventDate)
|
|||
|
|
.sort((a, b) => a.eventDate.getTime() - b.eventDate.getTime())
|
|||
|
|
|
|||
|
|
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export {
|
|||
|
|
APPLICATION_ARCHIVE_STAGE_LABEL,
|
|||
|
|
APPLICATION_LINK_STATUS_STEP_LABEL,
|
|||
|
|
APPLICATION_PROGRESS_LABELS,
|
|||
|
|
APPLICATION_PROGRESS_LABELS_WITHOUT_BUDGET,
|
|||
|
|
ARCHIVED_STEP_LABEL,
|
|||
|
|
DOCUMENT_BACKED_EXPENSE_TYPES,
|
|||
|
|
DOCUMENT_TYPE_APPLICATION,
|
|||
|
|
DOCUMENT_TYPE_REIMBURSEMENT,
|
|||
|
|
EXPENSE_TYPE_LABELS,
|
|||
|
|
HOTEL_DESCRIPTION_EXPENSE_TYPES,
|
|||
|
|
LOCATION_REQUIRED_EXPENSE_TYPES,
|
|||
|
|
LONG_DISTANCE_TRAVEL_EXPENSE_TYPES,
|
|||
|
|
RELATED_APPLICATION_STEP_LABEL,
|
|||
|
|
REIMBURSEMENT_PROGRESS_LABELS,
|
|||
|
|
ROUTE_DESCRIPTION_EXPENSE_TYPES,
|
|||
|
|
STANDARD_ADJUSTMENT_RISK_SOURCE,
|
|||
|
|
SYSTEM_GENERATED_EXPENSE_TYPES,
|
|||
|
|
buildOccurredDisplay,
|
|||
|
|
buildRiskMeta,
|
|||
|
|
buildRiskSummary,
|
|||
|
|
formatAmount,
|
|||
|
|
formatDate,
|
|||
|
|
formatDateTime,
|
|||
|
|
formatDurationFrom,
|
|||
|
|
getLatestEvent,
|
|||
|
|
getRiskFlags,
|
|||
|
|
isEmailLike,
|
|||
|
|
normalizeExpenseType,
|
|||
|
|
normalizeText,
|
|||
|
|
parseNumber,
|
|||
|
|
parseOptionalAmount,
|
|||
|
|
resolveApprovalMeta,
|
|||
|
|
resolveDisplayName,
|
|||
|
|
resolveDocumentTypeMeta,
|
|||
|
|
resolveTypeLabel,
|
|||
|
|
resolveWorkflowNode,
|
|||
|
|
toDate
|
|||
|
|
}
|