Files
X-Financial/web/src/views/scripts/travelReimbursementApplicationLinkModel.js

405 lines
13 KiB
JavaScript
Raw Normal View History

const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal'])
const APPLICATION_TYPE_ALIASES = {
travel: new Set(['travel', 'travel_application']),
meal: new Set([
'meal',
'entertainment',
'meal_application',
'entertainment_application',
'business_entertainment_application',
'hospitality_application'
])
}
const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application'])
const BLOCKED_APPLICATION_STATUSES = new Set(['draft', 'returned', 'rejected', 'cancelled', 'canceled', 'deleted'])
const STATUS_LABELS = {
submitted: '审批中',
approved: '已审批',
completed: '已完成',
archived: '已归档',
pending_payment: '待付款',
paid: '已付款'
}
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
meal: '业务招待费'
}
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeLower(value) {
return normalizeText(value).toLowerCase()
}
function uniqueValues(values) {
return Array.from(new Set((Array.isArray(values) ? values : []).map(normalizeText).filter(Boolean)))
}
function normalizeClaimNo(claim) {
return normalizeText(claim?.claim_no || claim?.claimNo).toUpperCase()
}
function normalizeExpenseType(claim) {
return normalizeLower(claim?.expense_type || claim?.expenseType || claim?.type_code || claim?.typeCode)
}
function normalizeApplicationStatus(claim) {
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
}
function normalizeDocumentType(claim) {
return normalizeLower(
claim?.document_type_code
|| claim?.documentTypeCode
|| claim?.document_type
|| claim?.documentType
)
}
function normalizeApplicationDate(claim) {
return normalizeText(
claim?.submitted_at
|| claim?.submittedAt
|| claim?.created_at
|| claim?.createdAt
|| claim?.occurred_at
|| claim?.occurredAt
)
}
function normalizeApplicationDateText(value) {
const text = normalizeText(value)
if (!text) {
return ''
}
const matched = text.match(/^(\d{4}-\d{2}-\d{2})/)
return matched?.[1] || text
}
function normalizeApplicationBusinessTime(claim) {
const detail = resolveApplicationDetailPayload(claim)
const start = normalizeApplicationDateText(
detail.start_date
|| detail.startDate
|| detail.departure_date
|| detail.departureDate
|| claim?.start_date
|| claim?.startDate
|| claim?.begin_date
|| claim?.beginDate
)
const end = normalizeApplicationDateText(
detail.end_date
|| detail.endDate
|| detail.return_date
|| detail.returnDate
|| claim?.end_date
|| claim?.endDate
|| claim?.finish_date
|| claim?.finishDate
)
if (start && end && start !== end) {
return `${start}${end}`
}
return normalizeApplicationDateText(
start
|| detail.application_business_time
|| detail.applicationBusinessTime
|| detail.business_time
|| detail.businessTime
|| detail.time_range
|| detail.timeRange
|| claim?.business_time
|| claim?.businessTime
|| claim?.time_range
|| claim?.timeRange
|| claim?.occurred_at
|| claim?.occurredAt
|| claim?.occurred_date
|| claim?.occurredDate
)
}
function resolveApplicationDetailPayload(claim) {
const flags = Array.isArray(claim?.risk_flags_json)
? claim.risk_flags_json
: Array.isArray(claim?.riskFlags)
? claim.riskFlags
: []
const detailFlag = flags.find((flag) => (
flag &&
typeof flag === 'object' &&
normalizeLower(flag.source) === 'application_detail'
))
const detail = detailFlag?.application_detail || detailFlag?.applicationDetail || {}
return detail && typeof detail === 'object' ? detail : {}
}
function toTimestamp(value) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
}
function formatAmount(value) {
const numberValue = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
if (!Number.isFinite(numberValue) || numberValue <= 0) {
return ''
}
return `¥${new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: Number.isInteger(numberValue) ? 0 : 2,
maximumFractionDigits: 2
}).format(numberValue)}`
}
function includesAny(text, keywords) {
const normalized = normalizeText(text)
return keywords.some((keyword) => normalized.includes(keyword))
}
function buildApplicationKeywordText(claim) {
return [
claim?.reason,
claim?.business_reason,
claim?.title,
claim?.summary,
claim?.description,
claim?.location,
claim?.business_location,
claim?.expense_type_label,
claim?.expenseTypeLabel
].map(normalizeText).filter(Boolean).join(' ')
}
function matchesGenericApplicationByText(claim, expenseType) {
const haystack = buildApplicationKeywordText(claim)
if (expenseType === 'travel') {
return includesAny(haystack, ['差旅', '出差', '住宿', '交通', '行程'])
}
if (expenseType === 'meal') {
return includesAny(haystack, ['招待', '客户', '接待', '宴请', '用餐', '餐饮'])
}
return false
}
export function requiresApplicationBeforeReimbursement(expenseType) {
return REQUIRED_APPLICATION_EXPENSE_TYPES.has(normalizeLower(expenseType))
}
export function getRequiredApplicationExpenseLabel(expenseType) {
return EXPENSE_TYPE_LABELS[normalizeLower(expenseType)] || '报销'
}
export function isExpenseApplicationClaim(claim) {
const documentType = normalizeDocumentType(claim)
const expenseType = normalizeExpenseType(claim)
const claimNo = normalizeClaimNo(claim)
return documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| expenseType === 'application'
|| expenseType.endsWith('_application')
}
export function matchesRequiredApplicationExpenseType(claim, expenseType) {
const normalizedExpenseType = normalizeLower(expenseType)
const claimExpenseType = normalizeExpenseType(claim)
const aliases = APPLICATION_TYPE_ALIASES[normalizedExpenseType] || new Set()
if (aliases.has(claimExpenseType)) {
return true
}
return GENERIC_APPLICATION_TYPES.has(claimExpenseType)
&& matchesGenericApplicationByText(claim, normalizedExpenseType)
}
export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
const userIds = uniqueValues([
currentUser.id,
currentUser.employeeId,
currentUser.employee_id,
currentUser.employeeNo,
currentUser.employee_no,
currentUser.username,
currentUser.email
])
const claimIds = uniqueValues([
claim?.employee_id,
claim?.employeeId,
claim?.employee_no,
claim?.employeeNo,
claim?.username,
claim?.user_id,
claim?.userId
])
if (userIds.length && claimIds.length && claimIds.some((item) => userIds.includes(item))) {
return true
}
const userNames = uniqueValues([
currentUser.name,
currentUser.user_name,
currentUser.employeeName,
currentUser.employee_name,
currentUser.username
])
const claimNames = uniqueValues([
claim?.employee_name,
claim?.employeeName,
claim?.applicant,
claim?.applicant_name,
claim?.applicantName
])
if (userNames.length && claimNames.length) {
return claimNames.some((item) => userNames.includes(item))
}
return true
}
export function isUsableRequiredApplicationClaim(claim) {
const status = normalizeApplicationStatus(claim)
return !BLOCKED_APPLICATION_STATUSES.has(status)
}
export function normalizeRequiredApplicationCandidate(claim) {
const detail = resolveApplicationDetailPayload(claim)
const claimNo = normalizeText(claim?.claim_no || claim?.claimNo)
const location = normalizeText(
detail.location
|| detail.application_location
|| claim?.location
|| claim?.business_location
|| claim?.businessLocation
)
const amount = normalizeText(
detail.amount
|| detail.application_amount
|| claim?.amount
|| claim?.budget_amount
|| claim?.budgetAmount
)
const amountText = formatAmount(amount)
const status = normalizeApplicationStatus(claim)
return {
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
claim_no: claimNo,
expense_type: normalizeExpenseType(claim),
reason: normalizeText(detail.reason || detail.application_reason || claim?.reason || claim?.business_reason || claim?.description || claim?.title),
location,
amount,
amount_label: amountText,
business_time: normalizeText(
detail.application_business_time
|| detail.applicationBusinessTime
|| detail.business_time
|| detail.businessTime
|| detail.time_range
|| detail.timeRange
|| detail.time
|| detail.application_time
) || normalizeApplicationBusinessTime(claim),
days: normalizeText(detail.days || detail.application_days),
transport_mode: normalizeText(detail.transport_mode || detail.application_transport_mode),
lodging_daily_cap: normalizeText(detail.lodging_daily_cap || detail.application_lodging_daily_cap),
subsidy_daily_cap: normalizeText(detail.subsidy_daily_cap || detail.application_subsidy_daily_cap),
transport_policy: normalizeText(detail.transport_policy || detail.application_transport_policy),
policy_estimate: normalizeText(detail.policy_estimate || detail.application_policy_estimate),
rule_name: normalizeText(detail.rule_name || detail.application_rule_name),
rule_version: normalizeText(detail.rule_version || detail.application_rule_version),
status,
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
application_date: normalizeApplicationDate(claim)
}
}
export function filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser = {}) {
const claims = Array.isArray(claimsPayload)
? claimsPayload
: Array.isArray(claimsPayload?.items)
? claimsPayload.items
: Array.isArray(claimsPayload?.claims)
? claimsPayload.claims
: []
return claims
.filter((claim) => (
isExpenseApplicationClaim(claim)
&& isUsableRequiredApplicationClaim(claim)
&& isClaimOwnedByCurrentUser(claim, currentUser)
&& matchesRequiredApplicationExpenseType(claim, expenseType)
))
.map(normalizeRequiredApplicationCandidate)
.sort((left, right) => toTimestamp(right.application_date) - toTimestamp(left.application_date))
}
export function buildRequiredApplicationActions(applications, actionType) {
return (Array.isArray(applications) ? applications : []).map((application) => {
const claimNo = normalizeText(application.claim_no) || '未编号申请单'
const description = [
application.status_label,
application.business_time && `时间:${application.business_time}`,
application.location && `地点:${application.location}`,
application.amount_label && `预算:${application.amount_label}`,
application.reason && `事由:${application.reason}`
].filter(Boolean).join(' · ')
return {
label: claimNo,
description,
icon: 'mdi mdi-file-link-outline',
action_type: actionType,
payload: {
application_claim_id: application.id,
application_claim_no: application.claim_no,
application_expense_type: application.expense_type,
application_reason: application.reason,
application_location: application.location,
application_amount: application.amount,
application_amount_label: application.amount_label,
application_business_time: application.business_time,
application_days: application.days,
application_transport_mode: application.transport_mode,
application_lodging_daily_cap: application.lodging_daily_cap,
application_subsidy_daily_cap: application.subsidy_daily_cap,
application_transport_policy: application.transport_policy,
application_policy_estimate: application.policy_estimate,
application_rule_name: application.rule_name,
application_rule_version: application.rule_version,
application_status: application.status,
application_status_label: application.status_label,
application_date: application.application_date
}
}
})
}
export function buildRequiredApplicationSelectionText(expenseType, applications) {
const label = getRequiredApplicationExpenseLabel(expenseType)
return [
`发起“${label}”报销前,需要先关联对应的申请单。`,
'',
`我查到 ${applications.length} 个可关联申请单,请先选择其中一个。`,
'选择后,我再继续向你收集本次报销依据。'
].join('\n')
}
export function buildRequiredApplicationMissingText(expenseType) {
const label = getRequiredApplicationExpenseLabel(expenseType)
return [
`发起“${label}”报销前,需要先关联对应的申请单。`,
'',
`我没有查到你名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`,
'请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。'
].join('\n')
}