- authUser 抽出 resolveAuthUserAdminFlag,统一 isAdmin 解析(含 superadmin、role_codes、中英文角色名),accessControl 复用同一逻辑 - 登录态、应用外壳路由、系统状态接入统一管理员判定,LoginView 与相关 composable 配套调整 - AI 工作台申请提交改为调用新的 /application-preview-action 接口,草稿保存仍走 orchestrator;预审模型补充重叠冲突提示与阻断判断 - 同步更新 accessControl/api-request/ai 预览动作等前端测试
379 lines
14 KiB
JavaScript
379 lines
14 KiB
JavaScript
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')
|
||
}
|