2026-06-20 10:17:37 +08:00
|
|
|
|
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'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 14:42:04 +08:00
|
|
|
|
export function isAiApplicationPrecheckBlocking(precheck = {}) {
|
|
|
|
|
|
return isBlockingPrecheck(precheck)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 10:17:37 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
2026-06-20 14:42:04 +08:00
|
|
|
|
|
|
|
|
|
|
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')
|
|
|
|
|
|
}
|