2026-05-21 23:53:03 +08:00
|
|
|
import {
|
|
|
|
|
EXPENSE_TYPE_LABELS,
|
|
|
|
|
formatAmountDisplay
|
|
|
|
|
} from './travelReimbursementReviewModel.js'
|
|
|
|
|
|
|
|
|
|
export const EXPENSE_QUERY_PAGE_SIZE = 5
|
2026-05-26 09:15:14 +08:00
|
|
|
export const EXPENSE_CENTER_HREF = '/app/documents'
|
2026-05-21 23:53:03 +08:00
|
|
|
export const ASSOCIATABLE_CLAIM_STATUSES = new Set(['draft', 'supplement', 'returned'])
|
|
|
|
|
const EXPENSE_STATUS_LABELS = {
|
|
|
|
|
draft: '草稿',
|
|
|
|
|
supplement: '待补充',
|
|
|
|
|
returned: '已退回',
|
|
|
|
|
submitted: '已提交',
|
|
|
|
|
review: '审批中',
|
|
|
|
|
approved: '已审核',
|
|
|
|
|
paid: '已入账'
|
|
|
|
|
}
|
2026-05-22 16:00:19 +08:00
|
|
|
const EXPENSE_RISK_LEVEL_LABELS = {
|
|
|
|
|
high: '高风险',
|
|
|
|
|
medium: '中风险',
|
|
|
|
|
warning: '中风险',
|
|
|
|
|
low: '低风险',
|
2026-05-22 23:47:28 +08:00
|
|
|
info: '提示'
|
2026-05-22 16:00:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function normalizeExpenseQueryRiskItem(item, index = 0) {
|
|
|
|
|
if (!item || typeof item !== 'object') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rawLevel = String(item.level || item.severity || '').trim().toLowerCase()
|
|
|
|
|
const level = EXPENSE_RISK_LEVEL_LABELS[rawLevel] ? rawLevel : 'medium'
|
|
|
|
|
const summary = String(item.summary || item.message || item.content || '').trim()
|
|
|
|
|
const detail = String(item.detail || item.description || summary).trim()
|
|
|
|
|
if (!summary && !detail) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
key: String(item.key || `${level}-${index}`).trim() || `${level}-${index}`,
|
|
|
|
|
level,
|
|
|
|
|
levelLabel: String(item.level_label || item.levelLabel || EXPENSE_RISK_LEVEL_LABELS[level]).trim() || EXPENSE_RISK_LEVEL_LABELS[level],
|
|
|
|
|
title: String(item.title || item.label || EXPENSE_RISK_LEVEL_LABELS[level]).trim() || EXPENSE_RISK_LEVEL_LABELS[level],
|
|
|
|
|
summary: summary || detail,
|
|
|
|
|
detail: detail || summary
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-21 23:53:03 +08:00
|
|
|
|
|
|
|
|
export function normalizeExpenseQueryStatusGroup(item) {
|
|
|
|
|
if (!item || typeof item !== 'object') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rawCount = Number(item.count || 0)
|
|
|
|
|
return {
|
|
|
|
|
key: String(item.key || 'other').trim() || 'other',
|
|
|
|
|
label: String(item.label || '其他状态').trim() || '其他状态',
|
|
|
|
|
count: Number.isFinite(rawCount) ? Math.max(0, rawCount) : 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function normalizeExpenseQueryRecord(item) {
|
|
|
|
|
if (!item || typeof item !== 'object') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const amount = Number(item.amount || 0)
|
|
|
|
|
const amountValue = Number.isFinite(amount) ? amount : 0
|
|
|
|
|
const expenseTypeLabel = String(item.expense_type_label || item.expense_type || '报销').trim() || '报销'
|
|
|
|
|
const reason = String(item.reason || '').trim()
|
|
|
|
|
const documentDate = String(item.document_date || '').trim()
|
|
|
|
|
const occurredAt = String(item.occurred_at || '').trim()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
claimId: String(item.claim_id || '').trim(),
|
|
|
|
|
claimNo: String(item.claim_no || '').trim() || '未编号',
|
|
|
|
|
employeeName: String(item.employee_name || '').trim(),
|
|
|
|
|
expenseType: String(item.expense_type || '').trim(),
|
|
|
|
|
expenseTypeLabel,
|
|
|
|
|
amount: amountValue,
|
|
|
|
|
amountDisplay: formatAmountDisplay(amountValue),
|
|
|
|
|
status: String(item.status || '').trim(),
|
|
|
|
|
statusLabel: String(item.status_label || '处理中').trim() || '处理中',
|
|
|
|
|
statusGroup: String(item.status_group || 'other').trim() || 'other',
|
|
|
|
|
statusGroupLabel: String(item.status_group_label || '其他状态').trim() || '其他状态',
|
|
|
|
|
approvalStage: String(item.approval_stage || '').trim(),
|
|
|
|
|
documentDate,
|
|
|
|
|
occurredAt,
|
|
|
|
|
reason,
|
|
|
|
|
location: String(item.location || '').trim(),
|
2026-05-22 16:00:19 +08:00
|
|
|
riskItems: (Array.isArray(item.risk_flags) ? item.risk_flags : [])
|
|
|
|
|
.map((riskItem, index) => normalizeExpenseQueryRiskItem(riskItem, index))
|
|
|
|
|
.filter(Boolean),
|
2026-05-21 23:53:03 +08:00
|
|
|
summary: reason || `${expenseTypeLabel}报销`,
|
|
|
|
|
dateDisplay: documentDate || occurredAt || '待补充日期'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveExpenseStatusGroup(status) {
|
|
|
|
|
const normalized = String(status || '').trim()
|
|
|
|
|
if (['draft', 'supplement', 'returned'].includes(normalized)) {
|
|
|
|
|
return { key: 'draft', label: normalized === 'draft' ? '草稿' : '待完善' }
|
|
|
|
|
}
|
|
|
|
|
if (['submitted', 'review'].includes(normalized)) {
|
|
|
|
|
return { key: 'in_progress', label: '审批中' }
|
|
|
|
|
}
|
|
|
|
|
if (['approved', 'paid'].includes(normalized)) {
|
|
|
|
|
return { key: 'completed', label: '已完成' }
|
|
|
|
|
}
|
|
|
|
|
return { key: 'other', label: '其他状态' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function formatQueryRecordDate(value) {
|
|
|
|
|
const text = String(value || '').trim()
|
|
|
|
|
if (!text) return ''
|
|
|
|
|
return text.includes('T') ? text.split('T')[0] : text.slice(0, 10)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildQueryRecordFromClaim(claim) {
|
|
|
|
|
if (!claim || typeof claim !== 'object') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
const claimId = String(claim.id || claim.claim_id || '').trim()
|
|
|
|
|
if (!claimId) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const status = String(claim.status || '').trim()
|
|
|
|
|
const statusGroup = resolveExpenseStatusGroup(status)
|
|
|
|
|
return {
|
|
|
|
|
claim_id: claimId,
|
|
|
|
|
claim_no: String(claim.claim_no || claim.claimNo || '').trim() || '未编号',
|
|
|
|
|
employee_name: String(claim.employee_name || claim.employeeName || '').trim(),
|
|
|
|
|
expense_type: String(claim.expense_type || claim.expenseType || '').trim(),
|
|
|
|
|
expense_type_label: EXPENSE_TYPE_LABELS[String(claim.expense_type || claim.expenseType || '').trim()] || String(claim.expense_type || claim.expenseType || '报销').trim(),
|
|
|
|
|
amount: Number(claim.amount || 0),
|
|
|
|
|
status,
|
|
|
|
|
status_label: EXPENSE_STATUS_LABELS[status] || statusGroup.label,
|
|
|
|
|
status_group: statusGroup.key,
|
|
|
|
|
status_group_label: statusGroup.label,
|
|
|
|
|
approval_stage: String(claim.approval_stage || claim.approvalStage || '').trim(),
|
|
|
|
|
document_date: formatQueryRecordDate(claim.submitted_at || claim.submittedAt || claim.created_at || claim.createdAt || claim.occurred_at || claim.occurredAt),
|
|
|
|
|
occurred_at: formatQueryRecordDate(claim.occurred_at || claim.occurredAt),
|
|
|
|
|
reason: String(claim.reason || '').trim(),
|
|
|
|
|
location: String(claim.location || '').trim()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildDraftAssociationQueryPayload(claims) {
|
|
|
|
|
const records = (Array.isArray(claims) ? claims : [])
|
|
|
|
|
.filter((claim) => ASSOCIATABLE_CLAIM_STATUSES.has(String(claim?.status || '').trim()))
|
|
|
|
|
.map(buildQueryRecordFromClaim)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
|
|
|
|
const statusGroups = records.reduce((groups, record) => {
|
|
|
|
|
const key = String(record.status_group || 'other')
|
|
|
|
|
const existing = groups.get(key) || {
|
|
|
|
|
key,
|
|
|
|
|
label: String(record.status_group_label || '其他状态'),
|
|
|
|
|
count: 0
|
|
|
|
|
}
|
|
|
|
|
existing.count += 1
|
|
|
|
|
groups.set(key, existing)
|
|
|
|
|
return groups
|
|
|
|
|
}, new Map())
|
|
|
|
|
|
|
|
|
|
return normalizeExpenseQueryPayload({
|
|
|
|
|
result_type: 'expense_claim_list',
|
|
|
|
|
title: '选择关联草稿',
|
|
|
|
|
scope_label: '可关联草稿',
|
|
|
|
|
selection_mode: 'draft_association',
|
|
|
|
|
empty_text: '当前没有可关联的草稿单据。',
|
|
|
|
|
recent_window_applied: false,
|
|
|
|
|
record_count: records.length,
|
|
|
|
|
preview_count: records.length,
|
|
|
|
|
total_amount: records.reduce((sum, record) => sum + Number(record.amount || 0), 0),
|
|
|
|
|
status_groups: Array.from(statusGroups.values()),
|
|
|
|
|
records
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function normalizeExpenseQueryPayload(payload) {
|
|
|
|
|
if (!payload || typeof payload !== 'object') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const resultType = String(payload.result_type || '').trim()
|
|
|
|
|
if (resultType && resultType !== 'expense_claim_list') {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const records = (Array.isArray(payload.records) ? payload.records : [])
|
|
|
|
|
.map(normalizeExpenseQueryRecord)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
const statusGroups = (Array.isArray(payload.status_groups) ? payload.status_groups : [])
|
|
|
|
|
.map(normalizeExpenseQueryStatusGroup)
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
|
|
|
|
|
const rawRecordCount = Number(payload.record_count || 0)
|
|
|
|
|
const rawPreviewCount = Number(payload.preview_count || records.length)
|
2026-05-22 16:00:19 +08:00
|
|
|
const rawPreviewLimit = Number(payload.preview_limit || EXPENSE_QUERY_PAGE_SIZE)
|
2026-05-21 23:53:03 +08:00
|
|
|
const rawOlderRecordCount = Number(payload.older_record_count || 0)
|
|
|
|
|
const totalAmount = Number(payload.total_amount || 0)
|
|
|
|
|
const rawWindowDays = Number(payload.window_days || 0)
|
|
|
|
|
const windowStartDate = String(payload.window_start_date || '').trim()
|
|
|
|
|
const windowEndDate = String(payload.window_end_date || '').trim()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
resultType: 'expense_claim_list',
|
|
|
|
|
scopeLabel: String(payload.scope_label || '报销单').trim() || '报销单',
|
|
|
|
|
selectionMode: String(payload.selection_mode || payload.selectionMode || '').trim(),
|
|
|
|
|
selectionLocked: Boolean(payload.selection_locked || payload.selectionLocked),
|
|
|
|
|
selectedClaimId: String(payload.selected_claim_id || payload.selectedClaimId || '').trim(),
|
|
|
|
|
title: String(payload.title || '').trim(),
|
|
|
|
|
emptyText: String(payload.empty_text || payload.emptyText || '').trim(),
|
|
|
|
|
recentWindowApplied: Boolean(payload.recent_window_applied),
|
|
|
|
|
windowDays:
|
|
|
|
|
payload.window_days === null || payload.window_days === undefined || payload.window_days === ''
|
|
|
|
|
? null
|
|
|
|
|
: (Number.isFinite(rawWindowDays) ? Math.max(1, rawWindowDays) : null),
|
|
|
|
|
windowStartDate: windowStartDate || '',
|
|
|
|
|
windowEndDate: windowEndDate || '',
|
|
|
|
|
recordCount: Number.isFinite(rawRecordCount) ? Math.max(0, rawRecordCount) : 0,
|
|
|
|
|
previewCount: Number.isFinite(rawPreviewCount) ? Math.max(0, rawPreviewCount) : records.length,
|
2026-05-22 16:00:19 +08:00
|
|
|
previewLimit: Number.isFinite(rawPreviewLimit) ? Math.max(1, rawPreviewLimit) : EXPENSE_QUERY_PAGE_SIZE,
|
2026-05-21 23:53:03 +08:00
|
|
|
olderRecordCount: Number.isFinite(rawOlderRecordCount) ? Math.max(0, rawOlderRecordCount) : 0,
|
|
|
|
|
hasMoreInWindow: Boolean(payload.has_more_in_window || payload.has_more),
|
|
|
|
|
totalAmount: Number.isFinite(totalAmount) ? totalAmount : 0,
|
|
|
|
|
statusGroups,
|
|
|
|
|
records,
|
|
|
|
|
currentPage: 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildExpenseQueryWindowLabel(queryPayload) {
|
|
|
|
|
if (!queryPayload) {
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (queryPayload.selectionMode === 'draft_association') {
|
|
|
|
|
return '先选择要关联的草稿,确认后我再识别附件并归集到该单据。'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (queryPayload.windowStartDate && queryPayload.windowEndDate) {
|
|
|
|
|
return `${queryPayload.windowStartDate} 至 ${queryPayload.windowEndDate}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (queryPayload.recentWindowApplied && queryPayload.windowDays) {
|
|
|
|
|
return `近 ${queryPayload.windowDays} 日内`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return '当前条件下'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getExpenseQueryTotalPages(queryPayload) {
|
|
|
|
|
const recordCount = Array.isArray(queryPayload?.records) ? queryPayload.records.length : 0
|
|
|
|
|
return Math.max(1, Math.ceil(recordCount / EXPENSE_QUERY_PAGE_SIZE))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getExpenseQueryActivePage(queryPayload) {
|
|
|
|
|
const totalPages = getExpenseQueryTotalPages(queryPayload)
|
|
|
|
|
const rawPage = Number(queryPayload?.currentPage || 1)
|
|
|
|
|
if (!Number.isFinite(rawPage)) {
|
|
|
|
|
return 1
|
|
|
|
|
}
|
|
|
|
|
return Math.min(Math.max(1, Math.round(rawPage)), totalPages)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getExpenseQueryVisibleRecords(queryPayload) {
|
|
|
|
|
const records = Array.isArray(queryPayload?.records) ? queryPayload.records : []
|
|
|
|
|
const activePage = getExpenseQueryActivePage(queryPayload)
|
|
|
|
|
const start = (activePage - 1) * EXPENSE_QUERY_PAGE_SIZE
|
|
|
|
|
return records.slice(start, start + EXPENSE_QUERY_PAGE_SIZE)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildExpenseQueryHint(queryPayload) {
|
|
|
|
|
if (!queryPayload) {
|
|
|
|
|
return ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (queryPayload.selectionMode === 'draft_association') {
|
|
|
|
|
if (queryPayload.selectionLocked && queryPayload.selectedClaimId) {
|
|
|
|
|
return '已选择关联草稿,附件将按该单据继续识别和归集。'
|
|
|
|
|
}
|
2026-05-26 09:15:14 +08:00
|
|
|
return '如果这些都不是本次要关联的单据,可以补充单号或先到单据中心新建草稿。'
|
2026-05-21 23:53:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parts = []
|
2026-05-22 16:00:19 +08:00
|
|
|
const previewLimit = Math.max(1, Number(queryPayload.previewLimit || EXPENSE_QUERY_PAGE_SIZE))
|
|
|
|
|
const totalCount = Math.max(0, Number(queryPayload.recordCount || 0))
|
2026-05-21 23:53:03 +08:00
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
if (totalCount > previewLimit) {
|
2026-05-26 09:15:14 +08:00
|
|
|
parts.push(`我只会筛选出最近的 ${previewLimit} 条记录;如果想查询全部的单据,请点击 [**这里**](${EXPENSE_CENTER_HREF}) 跳转到单据中心查看。`)
|
2026-05-22 16:00:19 +08:00
|
|
|
} else if (totalCount > 0) {
|
2026-05-26 09:15:14 +08:00
|
|
|
parts.push(`当前已展示本次筛选命中的全部记录;如果想进入单据中心继续筛选,请点击 [**这里**](${EXPENSE_CENTER_HREF})。`)
|
2026-05-21 23:53:03 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return parts.join('。')
|
|
|
|
|
}
|