Files
X-Financial/web/src/utils/aiApplicationPrecheckModel.js

379 lines
14 KiB
JavaScript
Raw Normal View History

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')
}