Files
X-Financial/web/src/composables/requests/requestShared.js
2026-06-23 11:21:18 +08:00

431 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { isApplicationDocumentNo } from '../../utils/documentClassification.js'
import { filterActionableRiskFlags, normalizeRiskFlagTone } from '../../utils/riskFlags.js'
import {
DOCUMENT_TYPE_APPLICATION,
DOCUMENT_TYPE_EXPENSE_APPLICATION,
DOCUMENT_TYPE_REIMBURSEMENT,
resolveDocumentTypeLabel
} from '../../constants/documentProtocol.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 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 === DOCUMENT_TYPE_EXPENSE_APPLICATION
|| isApplicationDocumentNo(claimNo)
|| normalizedType === 'application'
|| normalizedType.endsWith('_application')
return isApplication
? { documentTypeCode: DOCUMENT_TYPE_APPLICATION, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_APPLICATION) }
: { documentTypeCode: DOCUMENT_TYPE_REIMBURSEMENT, documentTypeLabel: resolveDocumentTypeLabel(DOCUMENT_TYPE_REIMBURSEMENT) }
}
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
}