feat: 增强风险规则生成引擎与预算中心页面
后端拆分风险规则生成为解释器、语义分析、本体对齐等子模块, 优化模板执行和流程图生成,完善员工种子数据和导入逻辑,增强 报销单权限策略和草稿持久化,前端新增预算中心视图和趋势图 组件,重构审计页面和风险规则测试对话框交互,完善文档中心 和报销创建页面细节,补充单元测试覆盖。
This commit is contained in:
@@ -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
|
||||
|
||||
105
web/src/utils/applicationApproval.js
Normal file
105
web/src/utils/applicationApproval.js
Normal 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)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ const APPLICATION_FIELD_PREFILLS = {
|
||||
reason: '事由:',
|
||||
days: '天数:',
|
||||
transport_mode: '出行方式:',
|
||||
amount: '预计总费用:'
|
||||
amount: '用户预估费用:'
|
||||
}
|
||||
|
||||
export function resolveSuggestedActionPrefill(action = {}) {
|
||||
|
||||
@@ -34,6 +34,7 @@ function isApplicationDocumentRequest(request) {
|
||||
return (
|
||||
documentType === 'application'
|
||||
|| documentType === 'expense_application'
|
||||
|| claimNo.startsWith('AP-')
|
||||
|| claimNo.startsWith('APP-')
|
||||
|| typeCode === 'application'
|
||||
|| typeCode.endsWith('_application')
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
47
web/src/utils/documentCenterRows.js
Normal file
47
web/src/utils/documentCenterRows.js
Normal 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))
|
||||
}
|
||||
22
web/src/utils/documentClassification.js
Normal file
22
web/src/utils/documentClassification.js
Normal 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')
|
||||
)
|
||||
}
|
||||
106
web/src/utils/expenseApplicationDetail.js
Normal file
106
web/src/utils/expenseApplicationDetail.js
Normal 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))
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
528
web/src/utils/expenseApplicationPreview.js
Normal file
528
web/src/utils/expenseApplicationPreview.js
Normal 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) 提交至审批流程。'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user