import { extractExpenseClaimItems } from '../services/reimbursements.js' import { isClaimOwnedByCurrentUser, isExpenseApplicationClaim, matchesRequiredApplicationExpenseType, normalizeRequiredApplicationCandidate } from '../views/scripts/travelReimbursementApplicationLinkModel.js' import { normalizeApplicationPreview, resolveApplicationDateRange } from './expenseApplicationPreview.js' const APPLICATION_BUDGET_REVIEW_THRESHOLD = 90 function normalizeText(value) { return String(value || '').trim() } function normalizeMoney(value) { if (typeof value === 'number') { return Number.isFinite(value) ? value : 0 } const normalized = normalizeText(value).replace(/,/g, '') const match = normalized.match(/-?\d+(?:\.\d+)?/) const amount = match ? Number(match[0]) : 0 return Number.isFinite(amount) && amount > 0 ? amount : 0 } function formatMoney(value) { const amount = normalizeMoney(value) if (!amount) { return '' } return `${new Intl.NumberFormat('zh-CN', { maximumFractionDigits: Number.isInteger(amount) ? 0 : 2, minimumFractionDigits: Number.isInteger(amount) ? 0 : 2 }).format(amount)}元` } function escapeMarkdownCell(value) { return normalizeText(value).replace(/\|/g, '\\|') || '-' } function buildApplicationDetailHref(item = {}) { const claimNo = normalizeText(item.claimNo) const reference = claimNo && claimNo !== '未编号申请单' ? claimNo : normalizeText(item.claimId) return reference ? `#ai-open-application-detail:${encodeURIComponent(reference)}` : '' } function buildApplicationDetailActionCell(item = {}) { const href = buildApplicationDetailHref(item) return href ? `[查看](${href})` : '-' } function parseDate(value) { const dateText = normalizeText(value) if (!/^\d{4}-\d{2}-\d{2}$/.test(dateText)) { return null } const date = new Date(`${dateText}T00:00:00Z`) return Number.isNaN(date.getTime()) ? null : date } function resolveDateRange(value, daysText = '') { const resolved = resolveApplicationDateRange(value, daysText) if (!resolved) { return null } const startText = normalizeText(resolved.startDate) const endText = normalizeText(resolved.endDate || resolved.startDate) const startDate = parseDate(startText) const endDate = parseDate(endText) if (!startDate || !endDate) { return null } return startDate <= endDate ? { startText, endText, startDate, endDate } : { startText: endText, endText: startText, startDate: endDate, endDate: startDate } } function rangesOverlap(left, right) { return Boolean(left && right && left.startDate <= right.endDate && right.startDate <= left.endDate) } function resolvePreviewDateRange(preview) { const fields = normalizeApplicationPreview(preview).fields || {} return resolveDateRange(fields.time, fields.days) } function resolvePreviewAmount(preview) { const normalized = normalizeApplicationPreview(preview) const fields = normalized.fields || {} const policyEstimate = normalized.policyEstimate && typeof normalized.policyEstimate === 'object' ? normalized.policyEstimate : {} return normalizeMoney( fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || policyEstimate.system_total_amount ) } function resolveApplicationClaims(claimsPayload, currentUser, expenseType) { return extractExpenseClaimItems(claimsPayload) .filter((claim) => ( isExpenseApplicationClaim(claim) && isClaimOwnedByCurrentUser(claim, currentUser) && matchesRequiredApplicationExpenseType(claim, expenseType) )) .map((claim) => normalizeRequiredApplicationCandidate(claim)) } function buildOverlapPrecheck(preview, claimsPayload, currentUser, expenseType) { const targetRange = resolvePreviewDateRange(preview) if (!targetRange) { return { status: 'unknown', summary: '暂未识别到完整出差日期,无法判断是否与已有申请时间重叠。' } } const applications = resolveApplicationClaims(claimsPayload, currentUser, expenseType) const matches = applications .map((application) => { const range = resolveDateRange(application.business_time) return { ...application, range } }) .filter((application) => rangesOverlap(targetRange, application.range)) .slice(0, 3) if (!matches.length) { return { status: 'ok', summary: `未发现 ${targetRange.startText} 至 ${targetRange.endText} 期间已有重叠的差旅申请单。`, matches: [] } } return { status: 'warning', summary: `发现 ${matches.length} 张同时间段可能重叠的申请单,暂不能继续发起新的出差申请。`, matches: matches.map((item) => ({ claimId: item.id || '', claimNo: item.claim_no || '未编号申请单', time: item.business_time || '', statusLabel: item.status_label || '', reason: item.reason || '' })) } } function isBlockingPrecheck(precheck = {}) { return precheck?.overlap?.status === 'warning' } export function isAiApplicationPrecheckBlocking(precheck = {}) { return isBlockingPrecheck(precheck) } function buildOverlapMatchTable(matches = []) { const rows = Array.isArray(matches) ? matches : [] if (!rows.length) { return '' } return [ '| 单据编号 | 申请时间 | 状态 | 事由 | 操作 |', '| --- | --- | --- | --- | --- |', ...rows.map((item) => [ escapeMarkdownCell(item.claimNo), escapeMarkdownCell(item.time), escapeMarkdownCell(item.statusLabel), escapeMarkdownCell(item.reason), buildApplicationDetailActionCell(item) ].join(' | ')).map((row) => `| ${row} |`) ].join('\n') } function resolveBudgetNumbers(summary = {}) { const totalAmount = normalizeMoney(summary.total_amount || summary.totalAmount) const reservedAmount = normalizeMoney(summary.reserved_amount || summary.reservedAmount) const consumedAmount = normalizeMoney(summary.consumed_amount || summary.consumedAmount) const availableAmount = normalizeMoney(summary.available_amount || summary.availableAmount) return { totalAmount, reservedAmount, consumedAmount, availableAmount, usedAmount: reservedAmount + consumedAmount } } function buildBudgetPrecheck(preview, budgetSummary) { const amount = resolvePreviewAmount(preview) const missingFields = normalizeApplicationPreview(preview).missingFields || [] if (!amount) { const reason = missingFields.includes('出行方式') ? '当前还缺出行方式,交通费用和申请总额暂未完成测算。' : '当前申请总额暂未完成测算。' return { status: 'pending', requiresBudgetReview: false, summary: `${reason}补齐后会刷新预算占用;若达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 预算复核线或超预算,系统会增加预算管理者审核。` } } if (!budgetSummary || typeof budgetSummary !== 'object') { return { status: 'unknown', requiresBudgetReview: false, summary: `本次预计申请金额 ${formatMoney(amount)}。预算接口暂未返回,以提交时系统预算复核为准;若达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线或超预算,会增加预算管理者审核。` } } const budget = resolveBudgetNumbers(budgetSummary) if (!budget.totalAmount) { return { status: 'unknown', requiresBudgetReview: false, summary: `本次预计申请金额 ${formatMoney(amount)}。当前部门预算总额暂未配置或暂未返回,提交时会继续做预算归口复核。` } } const afterUsed = budget.usedAmount + amount const afterUsageRate = Number(((afterUsed / budget.totalAmount) * 100).toFixed(2)) if (amount > budget.availableAmount) { return { status: 'warning', requiresBudgetReview: true, summary: `本次预计申请金额 ${formatMoney(amount)},当前可用预算 ${formatMoney(budget.availableAmount)},预计超出 ${formatMoney(amount - budget.availableAmount)},提交后需要预算管理者审核。` } } if (afterUsageRate >= APPLICATION_BUDGET_REVIEW_THRESHOLD) { return { status: 'warning', requiresBudgetReview: true, summary: `本次预计申请金额 ${formatMoney(amount)},审批后预算占用约 ${afterUsageRate}%,达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线,提交后需要预算管理者审核。` } } return { status: 'ok', requiresBudgetReview: false, summary: `本次预计申请金额 ${formatMoney(amount)},审批后预算占用约 ${afterUsageRate}%,未达到 ${APPLICATION_BUDGET_REVIEW_THRESHOLD}% 复核线。` } } export function buildAiApplicationPrecheck(preview = {}, { claimsPayload = null, budgetSummary = null, currentUser = {}, expenseType = 'travel', budgetError = null } = {}) { const normalizedPreview = normalizeApplicationPreview(preview) const budget = budgetError ? { status: 'unknown', requiresBudgetReview: false, summary: `预算接口暂未返回:${normalizeText(budgetError?.message || budgetError) || '当前无可用预算数据'}。提交时系统仍会按预算余额、风险规则判断是否增加预算管理者审核。` } : buildBudgetPrecheck(normalizedPreview, budgetSummary) return { overlap: buildOverlapPrecheck(normalizedPreview, claimsPayload, currentUser, expenseType), budget, missingFields: Array.isArray(normalizedPreview.missingFields) ? normalizedPreview.missingFields : [] } } export function buildAiApplicationPrecheckThinkingEvents(precheck = {}) { const blocked = isBlockingPrecheck(precheck) return [ { eventId: 'application-precheck-overlap', title: '核查同时间段申请单', content: precheck?.overlap?.summary || '已完成已有申请单核查。', status: precheck?.overlap?.status === 'warning' ? 'completed' : 'completed' }, { eventId: 'application-precheck-budget', title: '评估预算与审批影响', content: precheck?.budget?.summary || '已完成预算影响评估。', status: 'completed' }, { eventId: 'application-precheck-form', title: blocked ? '暂停生成申请表' : '生成申请表草稿', content: blocked ? '因发现同时间段已有申请单,已暂停生成新的申请表,等待用户核对申请时间。' : '已将识别到的时间、地点、事由和申请人信息预填到申请表。', status: 'completed' } ] } export function buildAiApplicationPrecheckMessage(preview = {}, precheck = {}) { if (isBlockingPrecheck(precheck)) { const matchTable = buildOverlapMatchTable(precheck?.overlap?.matches) const lines = [ '### 发现同时间段已有申请单', '', '**我已完成发起前的单据重叠核查**,当前不能继续生成新的出差申请表。', '', `> **时间重叠提醒**:${precheck?.overlap?.summary || '发现同时间段已有申请单,暂不能继续发起新的出差申请。'}`, ] if (matchTable) { lines.push('', matchTable) } lines.push( '', '> **请先核对**:请先检查本次申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先处理或关联已有申请单,避免重复申请。', '', '我会先暂停本次申请表生成,不会开放保存草稿或提交入口。' ) return lines.join('\n') } const normalized = normalizeApplicationPreview(preview) const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : [] const missingText = missingFields.length ? missingFields.join('、') : '暂无' const budgetPrefix = precheck?.budget?.requiresBudgetReview ? '**预算管理者审核提示**' : '**预算与审批影响**' const overlapPrefix = precheck?.overlap?.status === 'warning' ? '**时间重叠提醒**' : '**单据重叠核查**' const lines = [ '### 出差申请表草稿已生成', '', '**我已完成发起前的单据与预算预审**,并为您生成一张完整的出差申请表。', '', `> ${overlapPrefix}:${precheck?.overlap?.summary || '已完成已有单据核查。'}`, '', `> ${budgetPrefix}:${precheck?.budget?.summary || '已完成预算影响评估。'}`, '', `> **仍需补充**:${missingText}`, '', '请直接点击表格中的字段补充或修改;费用测算会根据地点、天数和出行方式自动更新。' ] if (missingFields.length) { lines.push('', `当前还需要补充:**${missingText}**。`) } else { lines.push('', '信息已基本齐全,您可以保存草稿,或直接提交进入审批。') } return lines.join('\n') } export function buildAiApplicationSubmitConflictMessage(preview = {}, precheck = {}) { const matchTable = buildOverlapMatchTable(precheck?.overlap?.matches) const normalized = normalizeApplicationPreview(preview) const fields = normalized.fields || {} const currentRange = resolveDateRange(fields.time, fields.days) const currentRangeText = currentRange ? `${currentRange.startText} 至 ${currentRange.endText}` : normalizeText(fields.time) || '待确认' const lines = [ '### 发现相同日期已有申请单', '', '**我已完成提交前的单据重叠核查**,发现相同或重叠日期已有差旅申请单,当前不能继续提交。', '', `> **相同日期提醒**:${precheck?.overlap?.summary || '发现相同日期已有申请单,请先核对后再提交。'}`, '', `> **本次申请时间**:${currentRangeText}`, ] if (matchTable) { lines.push('', matchTable) } lines.push( '', '> **请先核对**:请先核对申请时间是否填写正确。若日期填错,请直接回复正确的出发时间和返回时间,我会重新查询;若日期无误,请先查看或处理已有申请单,避免重复申请。', '', '我会先暂停本次提交,不会生成新的审批流。' ) return lines.join('\n') }