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 start = normalizeApplicationDateText(claim?.start_date || claim?.startDate || claim?.begin_date || claim?.beginDate) const end = normalizeApplicationDateText(claim?.end_date || claim?.endDate || claim?.finish_date || claim?.finishDate) if (start && end && start !== end) { return `${start} 至 ${end}` } return normalizeApplicationDateText( start || claim?.business_time || claim?.businessTime || claim?.time_range || claim?.timeRange || claim?.occurred_at || claim?.occurredAt || claim?.occurred_date || claim?.occurredDate ) } 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 claimNo = normalizeText(claim?.claim_no || claim?.claimNo) const location = normalizeText(claim?.location || claim?.business_location || claim?.businessLocation) const amountText = formatAmount(claim?.amount || claim?.budget_amount || claim?.budgetAmount) const status = normalizeApplicationStatus(claim) return { id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId), claim_no: claimNo, expense_type: normalizeExpenseType(claim), reason: normalizeText(claim?.reason || claim?.business_reason || claim?.description || claim?.title), location, amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount), amount_label: amountText, business_time: normalizeApplicationBusinessTime(claim), 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_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') }