Files
X-Financial/web/src/utils/aiApplicationPrecheckModel.js
caoxiaozhu 96c2e1099a feat(web): 统一平台管理员判定与 AI 工作台申请预览动作接入
- authUser 抽出 resolveAuthUserAdminFlag,统一 isAdmin 解析(含 superadmin、role_codes、中英文角色名),accessControl 复用同一逻辑
- 登录态、应用外壳路由、系统状态接入统一管理员判定,LoginView 与相关 composable 配套调整
- AI 工作台申请提交改为调用新的 /application-preview-action 接口,草稿保存仍走 orchestrator;预审模型补充重叠冲突提示与阻断判断
- 同步更新 accessControl/api-request/ai 预览动作等前端测试
2026-06-20 14:42:04 +08:00

379 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
}