feat: 增强风险规则生成引擎与预算中心页面

后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块,
优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强
报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图
组件,重构审计页面和风险规则测试对话框交互,完善文档中心
和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-26 09:15:14 +08:00
parent d0e946cf47
commit 0e861d8fa6
150 changed files with 14953 additions and 4099 deletions

View File

@@ -1,23 +1,20 @@
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'documents',
'requests',
'approval',
'archive',
'policies',
'audit',
export const DEFAULT_APP_VIEW_ORDER = [
'overview',
'workbench',
'documents',
'budget',
'policies',
'audit',
'logs',
'employees',
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'requests', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
approval: ['approver', 'finance', 'executive'],
archive: ['finance', 'executive', 'auditor'],
audit: ['auditor', 'finance'],
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
budget: ['finance', 'executive'],
audit: ['auditor', 'finance'],
logs: ['manager'],
employees: ['manager'],
settings: ['manager']
@@ -48,18 +45,22 @@ export function isExecutiveUser(user) {
return normalizedRoleCodes(user).includes('executive')
}
export function canManageExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canReturnExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
export function canManageExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_MANAGER_ROLE_CODES.has(roleCode))
}
export function canDeleteArchivedExpenseClaims(user) {
return Boolean(user?.isAdmin)
}
export function canReturnExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
@@ -72,14 +73,18 @@ export function canApproveLeaderExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (isManagerUser(user)) {
return true
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false
}
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
return false
}
if (isManagerUser(user)) {
return true
}
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
return true

View File

@@ -0,0 +1,105 @@
function normalizeText(value) {
return String(value || '').trim()
}
function isEmailLike(value) {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
}
function resolveDisplayName(...values) {
for (const value of values) {
const normalized = normalizeText(value)
if (normalized && !isEmailLike(normalized)) {
return normalized
}
}
return ''
}
function toDate(value) {
if (!value) {
return null
}
const nextDate = new Date(value)
return Number.isNaN(nextDate.getTime()) ? null : nextDate
}
function formatDateTime(value) {
const date = toDate(value)
if (!date) {
return ''
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
function getRiskFlags(request) {
const flags = request?.riskFlags || request?.risk_flags_json || []
return Array.isArray(flags) ? flags : []
}
function getLatestEvent(events) {
const sortedEvents = events
.filter((item) => item && typeof item === 'object')
.map((item) => ({ ...item, eventDate: toDate(item.created_at || item.createdAt) }))
.filter((item) => item.eventDate)
.sort((left, right) => left.eventDate.getTime() - right.eventDate.getTime())
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
}
export function findLeaderApprovalEvent(request) {
return getLatestEvent(
getRiskFlags(request).filter((flag) => {
const source = normalizeText(flag?.source)
const eventType = normalizeText(flag?.event_type || flag?.eventType)
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
return (
source === 'manual_approval'
&& (
eventType === 'expense_application_approval'
|| previousStage.includes('直属领导')
|| previousStage.includes('领导审批')
|| nextStage.includes('财务')
|| nextStage.includes('审批完成')
)
)
})
)
}
export function buildLeaderApprovalInfo(request) {
const event = findLeaderApprovalEvent(request)
if (!event) {
return {
opinion: '',
operator: '',
time: '',
generatedDraftClaimNo: ''
}
}
return {
opinion: normalizeText(event.opinion) || normalizeText(event.message),
operator: resolveDisplayName(
event.operator,
event.operator_name,
event.operatorName,
request?.profileManager,
request?.managerName
),
time: formatDateTime(event.created_at || event.createdAt),
generatedDraftClaimNo: normalizeText(event.generated_draft_claim_no || event.generatedDraftClaimNo)
}
}
export function resolveGeneratedDraftClaimNo(responsePayload) {
const event = findLeaderApprovalEvent({
riskFlags: responsePayload?.risk_flags_json || responsePayload?.riskFlags || []
})
return normalizeText(event?.generated_draft_claim_no || event?.generatedDraftClaimNo)
}

View File

@@ -5,7 +5,7 @@ const APPLICATION_FIELD_PREFILLS = {
reason: '事由:',
days: '天数:',
transport_mode: '出行方式:',
amount: '预计总费用:'
amount: '用户预估费用:'
}
export function resolveSuggestedActionPrefill(action = {}) {

View File

@@ -34,6 +34,7 @@ function isApplicationDocumentRequest(request) {
return (
documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')

View File

@@ -1,5 +1,6 @@
const STORAGE_KEY = 'x-financial.documents.viewed'
const SCOPE_STORAGE_KEY = 'x-financial.documents.scope'
export const DOCUMENT_VIEWED_KEYS_CHANGE_EVENT = 'x-financial.documents.viewed-change'
function getStorage() {
return typeof window === 'undefined' ? null : window.localStorage
@@ -30,6 +31,10 @@ export function writeViewedDocumentKeys(keys, storage = getStorage()) {
}
storage.setItem(STORAGE_KEY, JSON.stringify(Array.from(keys).filter(Boolean)))
if (typeof window !== 'undefined' && storage === window.localStorage) {
window.dispatchEvent(new CustomEvent(DOCUMENT_VIEWED_KEYS_CHANGE_EVENT))
}
}
export function readDocumentScope(fallback, allowedScopes = [], storage = getStorage()) {

View File

@@ -0,0 +1,47 @@
import { isApplicationRequestLike } from './documentClassification.js'
const ARCHIVED_CLAIM_STATUSES = new Set(['approved', 'completed', 'paid'])
function isArchivedRequestPayload(request) {
if (!request) {
return false
}
const normalizedStatus = String(request.status || '').trim().toLowerCase()
const stage = String(request.approval_stage || request.approvalStage || '').trim()
if (stage === '归档入账' || stage === 'completed') {
return true
}
if (stage.includes('归档') || stage.includes('入账')) {
return true
}
if (
isApplicationRequestLike(request)
&& ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
&& ['审批完成', '申请归档'].includes(stage)
) {
return true
}
return ARCHIVED_CLAIM_STATUSES.has(normalizedStatus)
&& (stage === '' || stage === '归档入账' || stage === 'completed')
}
export function isArchivedDocumentRow(row) {
if (!row) {
return false
}
if (row.archived === true) {
return true
}
return isArchivedRequestPayload(row.rawRequest || row)
}
export function excludeArchivedDocumentRows(rows) {
return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row))
}

View File

@@ -0,0 +1,22 @@
export function isApplicationRequestLike(value) {
const explicitType = String(
value?.documentTypeCode
|| value?.document_type_code
|| value?.documentType
|| value?.document_type
|| ''
).trim()
const claimNo = String(value?.claim_no || value?.claimNo || value?.documentNo || value?.id || '')
.trim()
.toUpperCase()
const typeCode = String(value?.typeCode || value?.expense_type || value?.expenseType || '').trim()
return (
explicitType === 'application'
|| explicitType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| typeCode === 'application'
|| typeCode.endsWith('_application')
)
}

View File

@@ -0,0 +1,106 @@
function normalizeText(value) {
return String(value || '').trim()
}
function isProvided(value) {
const text = normalizeText(value)
return Boolean(text) && !['待补充', '暂无', '无', '未知'].includes(text)
}
function resolveApplicationDetailPayload(request = {}) {
const flags = Array.isArray(request.risk_flags_json)
? request.risk_flags_json
: Array.isArray(request.riskFlags)
? request.riskFlags
: []
const detailFlag = flags.find((flag) =>
flag &&
typeof flag === 'object' &&
normalizeText(flag.source) === 'application_detail'
)
const detail = detailFlag?.application_detail || detailFlag?.applicationDetail || {}
return detail && typeof detail === 'object' ? detail : {}
}
function pickDetailValue(detail, request, keys = [], fallback = '') {
for (const key of keys) {
const value = normalizeText(detail[key] ?? request[key])
if (isProvided(value)) return value
}
return normalizeText(fallback)
}
export function buildApplicationDetailFactItems(request = {}) {
const detail = resolveApplicationDetailPayload(request)
const amountDisplay = normalizeText(request.amountDisplay || request.amount)
const rows = [
{
key: 'application_type',
label: '申请类型',
value: pickDetailValue(detail, request, ['application_type', 'typeLabel'], request.typeLabel)
},
{
key: 'time',
label: '发生时间',
value: pickDetailValue(detail, request, ['time', 'occurredDisplay', 'period'], request.occurredDisplay)
},
{
key: 'location',
label: '地点',
value: pickDetailValue(detail, request, ['location', 'sceneTarget', 'city'], request.sceneTarget)
},
{
key: 'reason',
label: '事由',
value: pickDetailValue(detail, request, ['reason'], request.reason)
},
{
key: 'days',
label: '天数',
value: pickDetailValue(detail, request, ['days'])
},
{
key: 'transport_mode',
label: '出行方式',
value: pickDetailValue(detail, request, ['transport_mode'])
},
{
key: 'grade',
label: '职级',
value: pickDetailValue(detail, request, ['grade', 'profileGrade', 'employee_grade'], request.profileGrade),
highlight: true
},
{
key: 'lodging_daily_cap',
label: '住宿上限/天',
value: pickDetailValue(detail, request, ['lodging_daily_cap']),
highlight: true
},
{
key: 'subsidy_daily_cap',
label: '补贴标准/天',
value: pickDetailValue(detail, request, ['subsidy_daily_cap']),
highlight: true
},
{
key: 'transport_policy',
label: '交通费用口径',
value: pickDetailValue(detail, request, ['transport_policy'], '车票、机票暂无实时价格接口,按真实票据实报实销')
},
{
key: 'policy_estimate',
label: '规则测算参考',
value: pickDetailValue(detail, request, ['policy_estimate']),
highlight: true
},
{
key: 'amount',
label: '用户预估费用',
value: pickDetailValue(detail, request, ['amount'], amountDisplay),
highlight: true,
emphasis: true
}
]
return rows.filter((row) => isProvided(row.value))
}

View File

@@ -47,6 +47,8 @@ const PROMPT_FIELD_LABELS = [
'出行方式',
'交通方式',
'交通工具',
'用户预估费用',
'预估费用',
'预计总费用',
'预计费用',
'预计金额',
@@ -186,10 +188,18 @@ export function expandApplicationTimeWithDays(timeText, days = 0) {
return `${formatApplicationDate(startDate)}${formatApplicationDate(endDate)}`
}
function normalizeApplicationTimeCandidate(value) {
const text = String(value || '').trim().replace(/^[,、。;;\s]+/, '')
if (!text) return ''
if (/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/.test(text)) return text
if (/今天|明天|后天|本周|下周|上周|本月|下月|月底|月初/.test(text)) return text
return ''
}
export function resolveApplicationTimeRange(ontology, prompt) {
const range = ontology?.time_range || {}
const baseTime = resolveTimeRangeText(ontology)
|| resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间'])
const baseTime = normalizeApplicationTimeCandidate(resolveTimeRangeText(ontology))
|| normalizeApplicationTimeCandidate(resolvePromptField(prompt, ['发生时间', '业务发生时间', '申请时间', '时间']))
if (range.start_date && range.end_date && range.start_date !== range.end_date) {
return `${range.start_date}${range.end_date}`
}
@@ -220,9 +230,94 @@ export function resolvePromptField(prompt, labels = []) {
return match ? match[1].trim().replace(/[,。;;]$/, '') : ''
}
export function resolveApplicationReason(prompt) {
function normalizeApplicationTransportMode(value) {
const text = String(value || '').trim()
if (/飞机|机票|航班/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
return text
}
function cleanupApplicationReasonCandidate(value, location = '') {
let text = String(value || '').trim()
if (!text) return ''
text = text
.replace(/^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)\s*[:]\s*/u, '')
.replace(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?/gu, '')
.replace(/(?:出差|申请)?(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/gu, '')
.replace(/(?:¥|¥)?\s*\d+(?:\.\d+)?\s*(?:元|块|万元|人民币)?/gu, '')
.replace(/(?:高铁|动车|火车|铁路|列车|飞机|机票|航班|轮船|船票|客轮|渡轮|邮轮)/gu, '')
.replace(/[,、。;;]+/g, '')
.replace(/^\s*(申请|费用申请|业务|本次|去|到|前往|赴)\s*/u, '')
.replace(/^[\s]+|[\s]+$/g, '')
.trim()
const normalizedLocation = String(location || '').trim()
if (normalizedLocation) {
const escapedLocation = escapeRegExp(normalizedLocation)
text = text
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:|,|、)?`, 'u'), '')
.replace(new RegExp(`^(?:去|到|前往|赴)${escapedLocation}(?:出差)?(?:|,|、)?`, 'u'), '')
.trim()
}
if (!text) return ''
if (/^20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?$/.test(text)) return ''
if (/^(?:\d+|[一二两三四五六七八九十]{1,3})\s*天$/.test(text)) return ''
if (/^[\u4e00-\u9fa5]{1,8}$/.test(text) && !/服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(text)) {
return ''
}
return text
}
function resolveApplicationLocationText(ontology, prompt) {
const locationEntity = resolveEntity(ontology, 'location')
return locationEntity?.normalized_value
|| locationEntity?.value
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点', '目的地'])
|| ''
}
export function resolveApplicationReason(prompt, ontology = null) {
const location = resolveApplicationLocationText(ontology, prompt)
const reasonEntity = resolveEntity(ontology, 'reason') || resolveEntity(ontology, 'business_reason')
const entityReason = String(reasonEntity?.normalized_value || reasonEntity?.value || '').trim()
if (entityReason) {
return cleanupApplicationReasonCandidate(entityReason, location) || entityReason
}
const labeled = resolvePromptField(prompt, ['事由', '申请事由', '出差事由', '原因', '用途'])
return labeled || String(prompt || '').trim()
if (labeled) {
return cleanupApplicationReasonCandidate(labeled, location) || labeled
}
const candidates = String(prompt || '')
.split(/[\n;]+/u)
.map((item) => cleanupApplicationReasonCandidate(item, location))
.filter(Boolean)
const businessCandidate = candidates.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item))
return businessCandidate || candidates.sort((left, right) => right.length - left.length)[0] || ''
}
function resolveApplicationTransportMode(ontology, prompt) {
const transportEntity = resolveEntity(ontology, 'transport_mode')
|| resolveEntity(ontology, 'transport')
const fromEntity = normalizeApplicationTransportMode(
transportEntity?.normalized_value || transportEntity?.value || ''
)
if (fromEntity) return fromEntity
const labeled = resolvePromptField(prompt, ['出行方式', '交通方式', '交通工具'])
const fromLabel = normalizeApplicationTransportMode(labeled)
if (fromLabel) return fromLabel
const text = String(prompt || '')
if (/飞机|机票|航班/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
return ''
}
export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
@@ -260,17 +355,16 @@ export function resolveAttachmentPolicy(expenseTypeCode, amount = 0) {
export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser = {}) {
const expenseTypeCode = resolveExpenseTypeCode(ontology)
const amount = resolveApplicationAmount(ontology)
const locationEntity = resolveEntity(ontology, 'location')
const documentTypeEntity = resolveEntity(ontology, 'document_type')
const workflowStageEntity = resolveEntity(ontology, 'workflow_stage')
const attachmentPolicy = resolveAttachmentPolicy(expenseTypeCode, amount.value)
const timeRange = resolveApplicationTimeRange(ontology, prompt)
|| '待补充'
const location = locationEntity?.normalized_value
|| locationEntity?.value
|| resolvePromptField(prompt, ['地点', '业务地点', '发生地点'])
const location = resolveApplicationLocationText(ontology, prompt)
|| '待补充'
const reason = resolveApplicationReason(prompt) || '待补充'
const reason = resolveApplicationReason(prompt, ontology) || '待补充'
const days = resolvePromptDays(prompt)
const transportMode = resolveApplicationTransportMode(ontology, prompt)
const fields = {
documentType: documentTypeEntity?.normalized_value || 'expense_application',
@@ -284,6 +378,8 @@ export function buildApplicationFieldsFromOntology(ontology, prompt, currentUser
timeRange,
location,
reason,
days: days ? `${days}` : '',
transportMode,
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充',
preApprovalRequired: PRE_APPROVAL_TYPES.has(expenseTypeCode),

View File

@@ -0,0 +1,528 @@
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
const APPLICATION_SESSION_TYPE = 'application'
const APPLICATION_CREATE_PATTERN = /申请|事前|前置|预算|出差|差旅|采购|会务|会议|培训|办公用品|交通|住宿/
const APPLICATION_QUERY_PATTERN = /查询|状态|进度|列表|有哪些|材料清单|需要哪些|制度|标准|规则|怎么规定/
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'applicationType', label: '申请类型' },
{ key: 'grade', label: '职级', highlight: true },
{ key: 'time', label: '发生时间' },
{ key: 'location', label: '地点' },
{ key: 'reason', label: '事由' },
{ key: 'days', label: '天数' },
{ key: 'transportMode', label: '出行方式' },
{ key: 'lodgingDailyCap', label: '住宿上限/天', highlight: true, editable: false, required: false },
{ key: 'subsidyDailyCap', label: '补贴标准/天', highlight: true, editable: false, required: false },
{ key: 'transportPolicy', label: '交通费用口径', highlight: true, editable: false, required: false },
{ key: 'policyEstimate', label: '规则测算参考', highlight: true, editable: false, required: false },
{ key: 'amount', label: '用户预估费用', highlight: true }
]
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '车票、机票暂无实时价格接口,按真实票据实报实销'
function compactText(value) {
return String(value || '').replace(/\s+/g, '')
}
function resolveFirstMatch(text, patterns = []) {
for (const pattern of patterns) {
const match = text.match(pattern)
const value = String(match?.groups?.value || match?.[1] || '').trim()
if (value) return value.replace(/[,。;;]$/, '')
}
return ''
}
function normalizeDateText(value) {
return String(value || '').replace(/[/.]/g, '-').replace(/\s+/g, '')
}
function parseIsoDate(value) {
const match = normalizeDateText(value).match(/^(20\d{2})-(\d{1,2})-(\d{1,2})$/)
if (!match) return null
const [, year, month, day] = match
const date = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
return Number.isNaN(date.getTime()) ? null : date
}
function formatIsoDate(date) {
return date.toISOString().slice(0, 10)
}
function buildEndDateFromDays(startText, daysText = '') {
const days = Number(String(daysText || '').replace(/[^\d]/g, ''))
const start = parseIsoDate(startText)
if (!days || !start) return ''
const end = new Date(start.getTime())
end.setUTCDate(end.getUTCDate() + days)
return formatIsoDate(end)
}
function resolveDaysFromDateRange(rangeText) {
const match = String(rangeText || '').match(/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*至\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/)
if (!match) return ''
const start = parseIsoDate(match[1])
const end = parseIsoDate(match[2])
if (!start || !end) return ''
const diffDays = Math.round((end.getTime() - start.getTime()) / 86400000)
return diffDays > 0 ? `${diffDays}` : '1天'
}
function resolveApplicationType(text) {
const compact = compactText(text)
if (/差旅|出差|高铁|动车|火车|飞机|机票|航班|酒店|住宿/.test(compact)) return '差旅费用申请'
if (/交通|出租车|的士|网约车|打车|通勤/.test(compact)) return '交通费用申请'
if (/住宿|酒店/.test(compact)) return '住宿费用申请'
if (/会务|会议|发布会|展会/.test(compact)) return '会务费用申请'
if (/采购|办公用品|文具|设备|耗材/.test(compact)) return '采购费用申请'
if (/培训|课程|学习/.test(compact)) return '培训费用申请'
return '费用申请'
}
function resolveApplicationAmount(text) {
const compact = compactText(text)
const labeled = resolveFirstMatch(text, [
/(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[:]?\s*(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)?/u,
/(?<value>\d+(?:\.\d+)?)\s*(?:元|块|人民币)/u
])
if (labeled) return `${labeled}`
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
return ''
}
function resolveCurrentUserGrade(currentUser = {}) {
return String(
currentUser.grade
|| currentUser.employeeGrade
|| currentUser.employee_grade
|| currentUser.profileGrade
|| ''
).trim()
}
function parseApplicationDaysValue(value) {
const match = String(value || '').match(/\d+/)
const days = match ? Number(match[0]) : 0
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
}
function parseMoneyNumber(value) {
const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
const amount = Number(normalized)
return Number.isFinite(amount) ? amount : null
}
function formatPolicyMoney(value) {
const amount = parseMoneyNumber(value)
if (amount === null) return String(value || '').trim()
return new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(amount)
}
function formatDailyPolicyMoney(value) {
const display = formatPolicyMoney(value)
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
}
function buildTransportPolicyText(transportMode) {
const mode = String(transportMode || '').trim()
if (!mode) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
return `${mode}票据暂无实时价格接口,按真实票据实报实销`
}
function ensureApplicationPolicyFields(fields = {}) {
const nextFields = { ...fields }
if (!String(nextFields.lodgingDailyCap || '').trim()) {
nextFields.lodgingDailyCap = APPLICATION_POLICY_PENDING_TEXT
}
if (!String(nextFields.subsidyDailyCap || '').trim()) {
nextFields.subsidyDailyCap = APPLICATION_POLICY_PENDING_TEXT
}
if (!String(nextFields.transportPolicy || '').trim() || /实报实销/.test(String(nextFields.transportPolicy || ''))) {
nextFields.transportPolicy = buildTransportPolicyText(nextFields.transportMode)
}
if (!String(nextFields.policyEstimate || '').trim()) {
nextFields.policyEstimate = APPLICATION_POLICY_PENDING_TEXT
}
return nextFields
}
function resolveApplicationDays(text) {
const value = resolveFirstMatch(text, [
/(?:出差|申请)?(?<value>\d+)\s*天/u,
/(?<value>\d+)\s*(?:个)?工作日/u
])
return value ? `${value}` : ''
}
function resolveApplicationTime(text, daysText = '') {
const range = text.match(
/(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})\s*(?:至|到|~|—||--)\s*(20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
)
if (range) {
return `${normalizeDateText(range[1])}${normalizeDateText(range[2])}`
}
const single = resolveFirstMatch(text, [
/(?:发生时间|业务发生时间|申请时间|时间)\s*[:]\s*(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u,
/(?<value>20\d{2}[-/.]\d{1,2}[-/.]\d{1,2})/u
])
if (!single) return ''
const normalized = normalizeDateText(single)
const endDate = buildEndDateFromDays(normalized, daysText)
return endDate ? `${normalized}${endDate}` : normalized
}
function resolveApplicationLocation(text) {
return resolveFirstMatch(text, [
/(?:地点|业务地点|发生地点|目的地)\s*[:]\s*(?<value>[^。;;\n]+)/u,
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
])
}
function resolveApplicationTransportMode(text) {
const compact = compactText(text)
if (/高铁|动车|火车|铁路/.test(compact)) return '火车'
if (/飞机|机票|航班/.test(compact)) return '飞机'
if (/轮船|船票|客轮|渡轮/.test(compact)) return '轮船'
return ''
}
function stripKnownContextFromReason(value, context = {}) {
const location = String(context.location || '').trim()
let cleaned = String(value || '')
.replace(/(?:发生时间|业务发生时间|申请时间|时间)\s*[:]\s*(?=[,、。;;\s]|$)/gu, '')
.replace(/(?:地点|业务地点|发生地点|目的地)\s*[:]\s*(?=[,、。;;\s]|$)/gu, '')
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}\s*(?:至|到|~|—||--)\s*20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
.replace(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/gu, '')
.replace(/(?:出差|申请)?\d+\s*天/gu, '')
.replace(/(?:用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)\s*[:]?\s*\d+(?:\.\d+)?\s*(?:元|块|人民币)?/gu, '')
.replace(/(?:高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮|出租车|的士|网约车|打车|自驾)/gu, '')
.replace(/[,、。;;]+/g, '')
.replace(/^\s*(去|到|前往)/u, '')
.replace(/^[\s]+|[\s]+$/g, '')
.trim()
if (location) {
const escapedLocation = location.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
cleaned = cleaned
.replace(new RegExp(`^${escapedLocation}(?:出差)?(?:|,|、)?`, 'u'), '')
.replace(new RegExp(`^(?:去|到|前往)${escapedLocation}(?:出差)?(?:|,|、)?`, 'u'), '')
.trim()
}
return cleaned
}
function pickBusinessReasonSegment(text) {
const segments = String(text || '')
.split(/[,、。;;\n]+/u)
.map((item) => item.trim())
.filter(Boolean)
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
}
function resolveApplicationReason(text, context = {}) {
const labeled = resolveFirstMatch(text, [
/(?:事由|申请事由|出差事由|原因|用途)\s*[:]\s*(?<value>[^,。;;\n]+)/u
])
if (labeled) return stripKnownContextFromReason(labeled, context)
const cleaned = String(text || '')
.replace(/^\s*(我想|我要|帮我)?\s*(先)?\s*(发起|提交|申请)?\s*(一笔)?\s*(费用申请|申请)\s*/u, '')
const withoutContext = stripKnownContextFromReason(cleaned, context)
const businessSegment = pickBusinessReasonSegment(withoutContext)
if (businessSegment) return stripKnownContextFromReason(businessSegment, context)
return withoutContext
}
function isApplicationPreviewValueProvided(value) {
const normalized = String(value || '').trim()
return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
}
function resolveProvidedValue(value, fallback = '') {
return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
}
function normalizeApplicationTypeLabel(value, fallback = '') {
const label = String(value || '').trim()
if (!label || label === '其他费用') return fallback || '费用申请'
if (label.endsWith('费用申请') || label.endsWith('申请')) return label
if (label.endsWith('费用')) return `${label}申请`
if (label.endsWith('费')) return `${label.slice(0, -1)}费用申请`
return `${label}申请`
}
function normalizeTransportModeOption(value, fallback = '') {
const text = String(value || '').trim()
if (/飞机|机票|航班/.test(text)) return '飞机'
if (/轮船|船票|客轮|渡轮|邮轮/.test(text)) return '轮船'
if (/火车|高铁|动车|铁路|列车/.test(text)) return '火车'
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
}
function normalizeAmountFromOntology(fields = {}, fallback = '') {
const numericAmount = Number(fields.amount || 0)
if (Number.isFinite(numericAmount) && numericAmount > 0) {
return `${numericAmount}`
}
const display = String(fields.amountDisplay || '').trim()
if (display && display !== '待补充') {
const normalized = display.replace(/^¥\s*/, '').replace(/,/g, '').trim()
return normalized.endsWith('元') ? normalized : `${normalized}`
}
return fallback
}
function buildMissingFields(fields) {
return APPLICATION_PREVIEW_FIELD_DEFINITIONS
.filter((item) => item.key !== 'applicationType' && item.required !== false)
.filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
.map((item) => item.label)
}
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
const days = parseApplicationDaysValue(fields.days)
const location = String(fields.location || '').trim()
const grade = String(fields.grade || resolveCurrentUserGrade(currentUser)).trim()
const applicationType = String(fields.applicationType || '').trim()
const transportMode = String(fields.transportMode || '').trim()
const shouldEstimate = /差旅|住宿|交通/.test(applicationType) || Boolean(transportMode)
if (!shouldEstimate || !days || !location) {
return {
canCalculate: false,
reason: '缺少地点或天数',
payload: null
}
}
return {
canCalculate: true,
reason: '',
payload: {
days,
location,
grade
}
}
}
export function applyApplicationPolicyEstimateResult(preview = {}, result = {}, currentUser = {}) {
const fields = { ...(preview?.fields || {}) }
const days = Number(result?.days) || parseApplicationDaysValue(fields.days) || 1
const hotelRate = formatPolicyMoney(result?.hotel_rate)
const hotelAmount = formatPolicyMoney(result?.hotel_amount)
const allowanceRate = formatPolicyMoney(result?.total_allowance_rate)
const allowanceAmount = formatPolicyMoney(result?.allowance_amount)
const totalAmount = formatPolicyMoney(result?.total_amount)
const matchedCity = String(result?.matched_city || fields.location || '').trim()
const grade = String(result?.grade || fields.grade || resolveCurrentUserGrade(currentUser)).trim()
return normalizeApplicationPreview({
...preview,
fields: {
...fields,
grade,
lodgingDailyCap: formatDailyPolicyMoney(result?.hotel_rate),
subsidyDailyCap: formatDailyPolicyMoney(result?.total_allowance_rate),
transportPolicy: buildTransportPolicyText(fields.transportMode),
policyEstimate: `住宿 ${hotelAmount}元 + 补贴 ${allowanceAmount}元 = ${totalAmount}元(${days}天,不含交通票据)`,
matchedCity,
ruleName: String(result?.rule_name || '').trim(),
ruleVersion: String(result?.rule_version || '').trim(),
hotelAmount: hotelAmount ? `${hotelAmount}` : '',
allowanceAmount: allowanceAmount ? `${allowanceAmount}` : '',
policyTotalAmount: totalAmount ? `${totalAmount}` : ''
},
policyEstimate: {
...result,
grade,
matchedCity
},
policyEstimateStatus: 'completed'
})
}
export function applyApplicationPolicyEstimateError(preview = {}, error = null, currentUser = {}) {
const fields = { ...(preview?.fields || {}) }
const message = String(error?.message || error || '').trim()
return normalizeApplicationPreview({
...preview,
fields: {
...fields,
grade: fields.grade || resolveCurrentUserGrade(currentUser),
transportPolicy: buildTransportPolicyText(fields.transportMode),
policyEstimate: message ? `规则中心暂未完成测算:${message}` : APPLICATION_POLICY_PENDING_TEXT
},
policyEstimateStatus: message ? 'failed' : 'pending'
})
}
export function shouldUseLocalApplicationPreview(rawText, options = {}) {
if (String(options.sessionType || '').trim() !== APPLICATION_SESSION_TYPE) return false
if (options.systemGenerated || options.reviewAction || Number(options.attachmentCount || 0) > 0) return false
const compact = compactText(rawText)
if (!compact || APPLICATION_QUERY_PATTERN.test(compact)) return false
return APPLICATION_CREATE_PATTERN.test(compact)
}
export function normalizeApplicationPreview(preview = {}) {
const fields = ensureApplicationPolicyFields(preview?.fields || {})
const missingFields = buildMissingFields(fields)
return {
...preview,
fields,
missingFields,
readyToSubmit: missingFields.length === 0
}
}
export function buildModelRefinedApplicationPreview(localPreview = {}, ontology = {}, rawText = '', currentUser = {}) {
const currentFields = localPreview?.fields || {}
const ontologyFields = buildApplicationFieldsFromOntology(ontology || {}, rawText, currentUser)
const parseStrategy = String(ontology?.parse_strategy || '').trim()
const refinedFields = {
...currentFields,
applicationType: normalizeApplicationTypeLabel(
ontologyFields.expenseTypeLabel,
currentFields.applicationType
),
time: resolveProvidedValue(ontologyFields.timeRange, currentFields.time),
location: resolveProvidedValue(ontologyFields.location, currentFields.location),
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
days: resolveProvidedValue(ontologyFields.days, currentFields.days),
transportMode: normalizeTransportModeOption(
ontologyFields.transportMode,
currentFields.transportMode
),
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),
department: resolveProvidedValue(ontologyFields.department, currentFields.department)
}
return normalizeApplicationPreview({
...localPreview,
sourceText: String(rawText || localPreview.sourceText || '').trim(),
fields: refinedFields,
modelRefined: true,
parseStrategy,
modelReviewStatus: parseStrategy === 'llm_primary' ? 'completed' : 'fallback'
})
}
export function buildApplicationPreviewRows(preview = {}) {
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return APPLICATION_PREVIEW_FIELD_DEFINITIONS.map((item) => {
const rawValue = fields[item.key]
const value = String(rawValue || '').trim() || '待补充'
return {
...item,
value,
editable: item.editable !== false,
highlight: Boolean(item.highlight),
missing: item.required !== false && !isApplicationPreviewValueProvided(rawValue)
}
})
}
export function buildApplicationPreviewSubmitText(preview = {}) {
const rows = buildApplicationPreviewRows(preview)
return [
'费用申请确认提交',
...rows.map((row) => `${row.label}${row.value}`),
'',
'确认提交'
].join('\n')
}
export function buildLocalApplicationPreview(rawText, currentUser = {}) {
const sourceText = String(rawText || '').trim()
const explicitDays = resolveApplicationDays(sourceText)
const time = resolveApplicationTime(sourceText, explicitDays)
const days = explicitDays || resolveDaysFromDateRange(time)
const location = resolveApplicationLocation(sourceText)
const fields = {
applicationType: resolveApplicationType(sourceText),
time,
location,
reason: resolveApplicationReason(sourceText, { location }),
days,
transportMode: resolveApplicationTransportMode(sourceText),
amount: resolveApplicationAmount(sourceText),
grade: resolveCurrentUserGrade(currentUser),
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充'
}
return normalizeApplicationPreview({
sourceText,
fields,
modelReviewStatus: 'local'
})
}
export function buildApplicationTemplatePreview(currentUser = {}) {
return normalizeApplicationPreview({
sourceText: '快速发起申请',
fields: {
applicationType: '费用申请',
time: '',
location: '',
reason: '',
days: '',
transportMode: '',
amount: '',
grade: resolveCurrentUserGrade(currentUser),
applicant: currentUser.name || currentUser.username || '当前用户',
department: currentUser.department || currentUser.departmentName || '待补充'
},
modelReviewStatus: 'template'
})
}
export function buildLocalApplicationPreviewMessage(preview) {
const normalized = normalizeApplicationPreview(preview)
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
return [
modelReviewStatus === 'completed'
? '我已完成模型复核,并整理成下方表格。请核查识别结果;点击对应行即可直接编辑。'
: modelReviewStatus === 'fallback'
? '模型复核没有返回稳定结果,我已先按规则兜底整理成下方表格。请重点核查识别结果;点击对应行即可直接编辑。'
: modelReviewStatus === 'failed'
? '模型复核暂时失败,我先保留一份临时核对表,方便您核查和补充信息。点击对应行即可直接编辑。'
: modelReviewStatus === 'template'
? '我已为你准备好费用申请模板。本步骤不调用大模型,也不会保存草稿;请点击对应行直接填写。'
: '我先整理出下方表格,请核查识别结果。点击对应行即可直接编辑。'
].join('\n')
}
export function buildApplicationPreviewFooterMessage(preview) {
const normalized = normalizeApplicationPreview(preview)
const missingFields = Array.isArray(normalized.missingFields) ? normalized.missingFields : []
const modelReviewStatus = String(normalized.modelReviewStatus || '').trim()
if (missingFields.length) {
return `当前还需要补充:${missingFields.join('、')}。补齐后我再帮您提交申请。`
}
if (modelReviewStatus === 'fallback') {
return '当前结果仅完成规则兜底复核,暂不直接提交。请核查表格内容,或稍后重新发起模型复核。'
}
if (modelReviewStatus === 'failed') {
return '当前结果仅作为临时预览,暂不直接提交。请稍后重试,或补充更明确的信息后再提交。'
}
return '请核对表格信息无误,确认无误后点击 [确认](#application-submit) 提交至审批流程。'
}

View File

@@ -1,3 +1,5 @@
import { isApplicationRequestLike } from './documentClassification.js'
export function isArchivedExpenseClaim(claim) {
const stage = String(claim?.approval_stage || claim?.approvalStage || '').trim()
const status = String(claim?.status || '').trim().toLowerCase()
@@ -10,5 +12,9 @@ export function isArchivedExpenseClaim(claim) {
return false
}
if (isApplicationRequestLike(claim) && ['审批完成', '申请归档'].includes(stage)) {
return true
}
return !stage || stage === '归档入账' || stage === 'completed'
}

View File

@@ -1,3 +1,5 @@
import { isApplicationRequestLike } from './documentClassification.js'
const REQUEST_TYPE_META = {
travel: {
label: '差旅费',
@@ -250,6 +252,29 @@ function resolveDisplayName(...values) {
return ''
}
export function isArchivedRequestView(request) {
const status = String(request?.status || '').trim().toLowerCase()
const approvalKey = String(request?.approvalKey || '').trim().toLowerCase()
const rawStage = String(request?.approval_stage || request?.approvalStage || '').trim()
const displayStage = String(request?.workflowNode || request?.node || '').trim()
const stage = rawStage || displayStage
if (stage === '归档入账' || stage === 'completed' || stage.includes('归档') || stage.includes('入账')) {
return true
}
if (
isApplicationRequestLike(request)
&& ['approved', 'completed', 'paid'].includes(status)
&& ['审批完成', '申请归档'].includes(stage)
) {
return true
}
if (['approved', 'completed', 'paid'].includes(status)) {
return rawStage === '' || rawStage === '归档入账' || rawStage === 'completed'
}
return approvalKey === 'completed'
}
export function normalizeRequestForUi(request) {
if (!request) {
return null