feat(web): 工作台 AI 模式报销预审与文档查询模型拆分
- 新增 aiApplicationPrecheckModel/aiDocumentQueryModel/aiApplicationPreviewActions/aiConversationHtmlRenderer 四个独立模型与服务,按职责从主组件拆出 - PersonalWorkbenchAiMode 接入拆分后的预审、文档查询与 HTML 渲染逻辑,配合 markdown 工具增强结构化展示 - 文档中心与归档筛选、风险可见性、申请预览等工具同步适配,补充对应单元测试 - 新增 AI 文档卡片背景资源
This commit is contained in:
345
web/src/utils/aiApplicationPrecheckModel.js
Normal file
345
web/src/utils/aiApplicationPrecheckModel.js
Normal file
@@ -0,0 +1,345 @@
|
||||
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'
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
Reference in New Issue
Block a user