import { isApplicationDocumentNo } from '../../utils/documentClassification.js' 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 APPROVED_APPLICATION_STATUSES = new Set(['approved', 'completed']) const APPROVED_APPLICATION_APPROVAL_KEYS = new Set(['completed']) const BLOCKED_APPLICATION_LINK_STATUSES = new Set(['draft', 'returned', 'rejected', 'archived', 'cancelled', 'canceled', 'deleted']) const INACTIVE_REIMBURSEMENT_LINK_STATUSES = new Set(['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 expandIdentityValues(values) { const expanded = [] ;(Array.isArray(values) ? values : []).forEach((value) => { const normalized = normalizeText(value) if (!normalized) { return } expanded.push(normalized) const atIndex = normalized.indexOf('@') if (atIndex > 0) { expanded.push(normalized.slice(0, atIndex)) } }) return uniqueValues(expanded) } 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 normalizeApprovalKey(claim) { return normalizeLower(claim?.approvalKey || claim?.approval_key) } 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 resolveRiskFlags(claim) { const flags = claim?.risk_flags_json || claim?.riskFlags || [] return Array.isArray(flags) ? flags : [] } function createReferenceIndex() { return { ids: new Set(), claimNos: new Set() } } function addApplicationReference(index, idValue, claimNoValue) { const id = normalizeText(idValue) if (id) { index.ids.add(id) } const claimNo = normalizeText(claimNoValue).toUpperCase() if (claimNo) { index.claimNos.add(claimNo) } } function addApplicationReferencesFromPayload(index, payload) { if (!payload || typeof payload !== 'object') { return } addApplicationReference( index, payload.application_claim_id || payload.applicationClaimId || payload.id, payload.application_claim_no || payload.applicationClaimNo || payload.claim_no || payload.claimNo ) } function collectLinkedApplicationReferences(claim) { const index = createReferenceIndex() addApplicationReferencesFromPayload(index, claim?.relatedApplication) addApplicationReferencesFromPayload(index, claim?.related_application) resolveRiskFlags(claim).forEach((flag) => { if (!flag || typeof flag !== 'object') { return } addApplicationReferencesFromPayload(index, flag) addApplicationReferencesFromPayload(index, flag.application_detail || flag.applicationDetail) addApplicationReferencesFromPayload(index, flag.review_form_values || flag.reviewFormValues) addApplicationReferencesFromPayload(index, flag.expense_scene_selection || flag.expenseSceneSelection) }) return index } function hasAnyApplicationReference(index) { return Boolean(index?.ids?.size || index?.claimNos?.size) } export function buildLinkedApplicationReferenceIndex(claims) { const index = createReferenceIndex() ;(Array.isArray(claims) ? claims : []).forEach((claim) => { if (isExpenseApplicationClaim(claim)) { return } const status = normalizeApplicationStatus(claim) if (INACTIVE_REIMBURSEMENT_LINK_STATUSES.has(status)) { return } const claimReferences = collectLinkedApplicationReferences(claim) claimReferences.ids.forEach((id) => index.ids.add(id)) claimReferences.claimNos.forEach((claimNo) => index.claimNos.add(claimNo)) }) return index } function isApplicationAlreadyLinked(claim, linkedApplicationReferences) { if (!linkedApplicationReferences) { return false } const ownReferences = createReferenceIndex() addApplicationReference( ownReferences, claim?.id || claim?.claim_id || claim?.claimId, claim?.claim_no || claim?.claimNo ) if (!hasAnyApplicationReference(ownReferences)) { return false } return Array.from(ownReferences.ids).some((id) => linkedApplicationReferences.ids.has(id)) || Array.from(ownReferences.claimNos).some((claimNo) => linkedApplicationReferences.claimNos.has(claimNo)) } 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 resolveRequiredApplicationReimbursementType(application = {}) { const expenseType = normalizeLower( application.application_expense_type || application.expense_type || application.expenseType || application.type_code || application.typeCode ) const source = { expense_type: expenseType, reason: application.application_reason || application.reason, title: application.application_reason || application.reason, description: application.application_reason || application.reason, location: application.application_location || application.location } if (APPLICATION_TYPE_ALIASES.travel.has(expenseType)) { return { expenseType: 'travel', expenseTypeLabel: EXPENSE_TYPE_LABELS.travel } } if (APPLICATION_TYPE_ALIASES.meal.has(expenseType)) { return { expenseType: 'meal', expenseTypeLabel: EXPENSE_TYPE_LABELS.meal } } if (matchesGenericApplicationByText(source, 'meal')) { return { expenseType: 'meal', expenseTypeLabel: EXPENSE_TYPE_LABELS.meal } } if (matchesGenericApplicationByText(source, 'travel')) { return { expenseType: 'travel', expenseTypeLabel: EXPENSE_TYPE_LABELS.travel } } return { expenseType: REQUIRED_APPLICATION_EXPENSE_TYPES.has(expenseType) ? expenseType : 'travel', expenseTypeLabel: getRequiredApplicationExpenseLabel( REQUIRED_APPLICATION_EXPENSE_TYPES.has(expenseType) ? expenseType : 'travel' ) } } export function isExpenseApplicationClaim(claim) { const documentType = normalizeDocumentType(claim) const expenseType = normalizeExpenseType(claim) const claimNo = normalizeClaimNo(claim) return documentType === 'application' || documentType === 'expense_application' || isApplicationDocumentNo(claimNo) || 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 = expandIdentityValues([ currentUser.id, currentUser.employeeId, currentUser.employee_id, currentUser.employeeNo, currentUser.employee_no, currentUser.username, currentUser.email ]) const claimIds = expandIdentityValues([ claim?.employee_id, claim?.employeeId, claim?.employee_no, claim?.employeeNo, claim?.employee_email, claim?.employeeEmail, 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, linkedApplicationReferences = null) { const status = normalizeApplicationStatus(claim) const approvalKey = normalizeApprovalKey(claim) if (BLOCKED_APPLICATION_LINK_STATUSES.has(status)) { return false } return (APPROVED_APPLICATION_STATUSES.has(status) || APPROVED_APPLICATION_APPROVAL_KEYS.has(approvalKey)) && !isApplicationAlreadyLinked(claim, linkedApplicationReferences) } 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 : [] const linkedApplicationReferences = buildLinkedApplicationReferenceIndex(claims) return claims .filter((claim) => ( isExpenseApplicationClaim(claim) && isUsableRequiredApplicationClaim(claim, linkedApplicationReferences) && 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') }