refactor: enforce 800 line source limits
This commit is contained in:
@@ -1,752 +1,56 @@
|
||||
import { buildApplicationFieldsFromOntology } from './expenseApplicationOntology.js'
|
||||
import { evaluateLocalApplicationIntentGate } from './expenseApplicationIntentGate.js'
|
||||
import {
|
||||
buildMockApplicationTransportEstimate,
|
||||
formatApplicationEstimateMoney,
|
||||
parseApplicationEstimateMoney,
|
||||
buildSystemApplicationEstimate
|
||||
} from './expenseApplicationEstimate.js'
|
||||
import { getTodayDateValue } from './workbenchComposerDate.js'
|
||||
|
||||
export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
|
||||
{ key: 'applicationType', label: '申请类型' },
|
||||
{ key: 'applicant', label: '姓名', editable: false, required: false },
|
||||
{ key: 'grade', label: '职级', highlight: true, editable: false, required: false },
|
||||
{ key: 'department', label: '部门', editable: false, required: false },
|
||||
{ key: 'position', label: '岗位', editable: false, required: false },
|
||||
{ key: 'managerName', label: '直属领导', editable: false, required: false },
|
||||
{ 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, editable: false, required: false }
|
||||
]
|
||||
|
||||
export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
|
||||
|
||||
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
||||
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
|
||||
|
||||
export function resolveApplicationTimeLabel(applicationType = '') {
|
||||
const label = String(applicationType || '').trim()
|
||||
if (/差旅|出差/.test(label)) return '出发时间'
|
||||
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
|
||||
return '申请时间'
|
||||
}
|
||||
|
||||
function resolveApplicationFieldLabel(item, fields = {}) {
|
||||
if (item.key === 'time') {
|
||||
return resolveApplicationTimeLabel(fields.applicationType)
|
||||
}
|
||||
return item.label
|
||||
}
|
||||
|
||||
function isTravelApplicationType(applicationType = '') {
|
||||
return /差旅|出差/.test(String(applicationType || '').trim())
|
||||
}
|
||||
|
||||
function resolveApplicationTripDateParts(fields = {}) {
|
||||
const timeText = String(fields.time || '').trim()
|
||||
const matchedDates = timeText.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || timeText)
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, fields.days)
|
||||
|
||||
return {
|
||||
startDate,
|
||||
endDate: inferredEndDate || explicitEndDate || startDate
|
||||
}
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return String(value || '').replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function looksLikeStructuredTravelApplication(text) {
|
||||
const source = String(text || '')
|
||||
return /(?:发生时间|业务发生时间|申请时间|时间)\s*[::]/.test(source)
|
||||
&& /(?:地点|业务地点|发生地点|目的地)\s*[::]/.test(source)
|
||||
&& /(?:天数|出差天数|申请天数)\s*[::]?\s*(?:\d+|[一二两三四五六七八九十]{1,3})\s*天/.test(source)
|
||||
}
|
||||
|
||||
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 = parseApplicationDaysValue(daysText)
|
||||
const start = parseIsoDate(startText)
|
||||
if (!days || !start) return ''
|
||||
const end = new Date(start.getTime())
|
||||
end.setUTCDate(end.getUTCDate() + Math.max(days - 1, 0))
|
||||
return formatIsoDate(end)
|
||||
}
|
||||
|
||||
function buildDateFromMonthDay(year, month, day) {
|
||||
const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
return parseIsoDate(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function resolveShortMonthDayRange(text, options = {}) {
|
||||
const match = String(text || '').match(
|
||||
/(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?\s*(?:至|到|~|—|–|--|-)\s*(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日/u
|
||||
)
|
||||
if (!match?.groups) return ''
|
||||
|
||||
const referenceYear = Number(resolvePreviewToday(options).slice(0, 4))
|
||||
const startMonth = Number(match.groups.startMonth)
|
||||
const startDay = Number(match.groups.startDay)
|
||||
const endMonth = Number(match.groups.endMonth || match.groups.startMonth)
|
||||
const endDay = Number(match.groups.endDay)
|
||||
const startDate = buildDateFromMonthDay(referenceYear, startMonth, startDay)
|
||||
const endYear = endMonth < startMonth ? referenceYear + 1 : referenceYear
|
||||
const endDate = buildDateFromMonthDay(endYear, endMonth, endDay)
|
||||
if (!startDate || !endDate) return ''
|
||||
return startDate === endDate ? startDate : `${startDate} 至 ${endDate}`
|
||||
}
|
||||
|
||||
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}天` : ''
|
||||
}
|
||||
|
||||
export function resolveApplicationDaysFromDateRange(rangeText) {
|
||||
return resolveDaysFromDateRange(rangeText)
|
||||
}
|
||||
|
||||
function resolveApplicationValidationIssues(fields = {}) {
|
||||
const issues = []
|
||||
const rangeDaysText = resolveDaysFromDateRange(fields.time)
|
||||
const rangeDays = parseApplicationDaysValue(rangeDaysText)
|
||||
const explicitDays = parseApplicationDaysValue(fields.days)
|
||||
if (rangeDays && explicitDays && rangeDays !== explicitDays) {
|
||||
issues.push({
|
||||
code: 'time_days_conflict',
|
||||
field: 'days',
|
||||
message: `申请时间 ${fields.time} 按自然日为 ${rangeDays} 天,但填写的是 ${explicitDays} 天。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
function shouldTrustModelApplicationFields(preview = {}) {
|
||||
const status = String(preview?.modelReviewStatus || '').trim()
|
||||
const strategy = String(preview?.parseStrategy || preview?.parse_strategy || '').trim()
|
||||
return Boolean(preview?.modelRefined)
|
||||
|| status === 'completed'
|
||||
|| strategy === 'llm_primary'
|
||||
}
|
||||
|
||||
function resolveApplicationSourceValidationIssues(sourceText = '', fields = {}, preview = {}) {
|
||||
if (shouldTrustModelApplicationFields(preview)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const issues = []
|
||||
const locationCandidates = extractApplicationLocationCandidates(sourceText)
|
||||
if (locationCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'location_candidates_conflict',
|
||||
field: 'location',
|
||||
message: `当前输入里同时出现多个地点:${locationCandidates.join('、')}。请确认本次申请地点。`
|
||||
})
|
||||
}
|
||||
|
||||
const transportCandidates = extractApplicationTransportCandidates(sourceText)
|
||||
if (transportCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'transport_candidates_conflict',
|
||||
field: 'transportMode',
|
||||
message: `当前输入里同时出现多个出行方式:${transportCandidates.join('、')}。请确认本次申请使用哪一种。`
|
||||
})
|
||||
}
|
||||
|
||||
const amountCandidates = extractApplicationAmountCandidates(sourceText)
|
||||
if (amountCandidates.length > 1) {
|
||||
issues.push({
|
||||
code: 'amount_candidates_conflict',
|
||||
field: 'amount',
|
||||
message: `当前输入里同时出现多个金额:${amountCandidates.join('、')}。请确认本次申请金额。`
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
export function shouldRequireApplicationModelReview(rawText = '') {
|
||||
const text = String(rawText || '').trim()
|
||||
const compact = compactText(text)
|
||||
if (!compact) return false
|
||||
|
||||
const hasDateRange = /(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|(?:\d{1,2}月)?\d{1,2}日?)\s*(?:至|到|~|—|–|--|-)\s*(?:20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}|\d{1,2}月?\d{1,2}日?)/u.test(text)
|
||||
const hasTravelIntent = /差旅|出差|去|到|赴|前往/.test(compact)
|
||||
const hasTransport = /高铁|动车|火车|铁路|飞机|机票|航班|轮船|船票|客轮|渡轮/.test(compact)
|
||||
const hasBusinessPurpose = /服务|支撑|支持|辅助|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(compact)
|
||||
const hasInlinePurpose = hasBusinessPurpose && !/(?:事由|申请事由|出差事由|原因|用途)\s*[::]/.test(text)
|
||||
const hasMultipleClauses = /[,,。;;\n]/.test(text) || compact.length >= 24
|
||||
const signalCount = [hasDateRange, hasTravelIntent, hasTransport, hasBusinessPurpose].filter(Boolean).length
|
||||
|
||||
return (hasInlinePurpose && signalCount >= 2) || (hasMultipleClauses && signalCount >= 3)
|
||||
}
|
||||
|
||||
export function resolveApplicationDateRange(rangeText, daysText = '') {
|
||||
const matchedDates = String(rangeText || '').match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) || []
|
||||
const startDate = normalizeDateText(matchedDates[0] || '')
|
||||
const explicitEndDate = normalizeDateText(matchedDates[matchedDates.length - 1] || '')
|
||||
const inferredEndDate = explicitEndDate && explicitEndDate !== startDate
|
||||
? explicitEndDate
|
||||
: buildEndDateFromDays(startDate, daysText)
|
||||
const endDate = inferredEndDate || explicitEndDate || startDate
|
||||
const start = parseIsoDate(startDate)
|
||||
const end = parseIsoDate(endDate)
|
||||
if (!start || !end) {
|
||||
return null
|
||||
}
|
||||
const orderedStart = start.getTime() <= end.getTime() ? start : end
|
||||
const orderedEnd = start.getTime() <= end.getTime() ? end : start
|
||||
return {
|
||||
startDate: formatIsoDate(orderedStart),
|
||||
endDate: formatIsoDate(orderedEnd),
|
||||
startTime: orderedStart.getTime(),
|
||||
endTime: orderedEnd.getTime()
|
||||
}
|
||||
}
|
||||
|
||||
export function applicationDateRangesOverlap(leftRange, rightRange) {
|
||||
if (!leftRange || !rightRange) {
|
||||
return false
|
||||
}
|
||||
return leftRange.startTime <= rightRange.endTime && rightRange.startTime <= leftRange.endTime
|
||||
}
|
||||
|
||||
function resolvePreviewToday(options = {}) {
|
||||
const explicitToday = String(options.today || options.currentDate || '').trim()
|
||||
if (parseIsoDate(explicitToday)) return normalizeDateText(explicitToday)
|
||||
if (options.now instanceof Date && !Number.isNaN(options.now.getTime())) {
|
||||
return getTodayDateValue(options.now)
|
||||
}
|
||||
return getTodayDateValue()
|
||||
}
|
||||
|
||||
function resolveApplicationType(text) {
|
||||
const compact = compactText(text)
|
||||
if (looksLikeStructuredTravelApplication(text)) return '差旅费用申请'
|
||||
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*(?:万|千|k|K)?)/u,
|
||||
/(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/u
|
||||
])
|
||||
const normalized = normalizeApplicationAmountText(labeled)
|
||||
if (normalized) return normalized
|
||||
if (/不知道预算|预算不清楚|预算待定|待测算/.test(compact)) return '待测算'
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeApplicationAmountText(value) {
|
||||
const text = String(value || '').replace(/[,,]/g, '').trim()
|
||||
const match = text.match(/(?<number>\d+(?:\.\d+)?)\s*(?<unit>万|千|k|K)?/u)
|
||||
if (!match?.groups) return ''
|
||||
let amount = Number(match.groups.number)
|
||||
if (!Number.isFinite(amount) || amount <= 0) return ''
|
||||
const unit = String(match.groups.unit || '').toLowerCase()
|
||||
if (unit === '万') amount *= 10000
|
||||
if (unit === '千' || unit === 'k') amount *= 1000
|
||||
return `${Number.isInteger(amount) ? amount : Number(amount.toFixed(2))}元`
|
||||
}
|
||||
|
||||
function extractApplicationLocationCandidates(text) {
|
||||
const candidates = []
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
if (labeled) candidates.push(normalizeLocationCandidate(labeled))
|
||||
|
||||
const compact = compactText(text)
|
||||
const patterns = [
|
||||
/(?:去|到|赴|前往)(?<value>[\u4e00-\u9fa5]{1,24})/gu,
|
||||
/(?<value>[\u4e00-\u9fa5]{1,12})(?:出差|驻场)/gu
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
for (const match of compact.matchAll(pattern)) {
|
||||
candidates.push(normalizeLocationCandidate(match.groups?.value || ''))
|
||||
}
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
.filter((item) => !isInvalidApplicationLocationCandidate(item))
|
||||
}
|
||||
|
||||
function normalizeLocationCandidate(value) {
|
||||
let cleaned = String(value || '').replace(/\s+/g, '')
|
||||
for (const marker of ['前往', '去', '到', '赴']) {
|
||||
if (cleaned.includes(marker)) {
|
||||
cleaned = cleaned.slice(cleaned.lastIndexOf(marker) + marker.length)
|
||||
break
|
||||
}
|
||||
}
|
||||
cleaned = cleaned
|
||||
.replace(/^(?:去|到|赴|前往)/u, '')
|
||||
.replace(/(?:出差|驻场|现场|支撑|支持|部署|上线|实施|拜访|验收|会议|采购|培训|协助|处理|办理|参加|进行).*$/u, '')
|
||||
.replace(/[::,,。;;、\s]/g, '')
|
||||
return cleaned.replace(/^(北京|上海|天津|重庆)市$/u, '$1')
|
||||
}
|
||||
|
||||
function isInvalidApplicationLocationCandidate(value) {
|
||||
const compact = compactText(value)
|
||||
if (!compact) return true
|
||||
if (/^(?:类型|申请类型|费用类型|报销类型)$/.test(compact)) return true
|
||||
if (/^(?:费用申请|差旅费用申请|交通费用申请|住宿费用申请|会务费用申请|采购费用申请|培训费用申请|报销申请|申请)$/.test(compact)) return true
|
||||
if (/(?:类型|申请类型|费用类型|报销类型)/.test(compact)) return true
|
||||
if (/(?:交通方式|出行方式|交通工具|预算|金额|费用|票据|附件|天数|时间|事由|原因|用途)/.test(compact)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function extractApplicationTransportCandidates(text) {
|
||||
const compact = compactText(text)
|
||||
return uniqueApplicationCandidates([
|
||||
resolveApplicationTransportMode(resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])),
|
||||
/高铁|动车|火车|铁路/.test(compact) ? '火车' : '',
|
||||
/飞机|机票|航班/.test(compact) ? '飞机' : '',
|
||||
/轮船|船票|客轮|渡轮|邮轮/.test(compact) ? '轮船' : ''
|
||||
])
|
||||
}
|
||||
|
||||
function extractApplicationAmountCandidates(text) {
|
||||
const candidates = []
|
||||
const source = String(text || '')
|
||||
const labelPattern = /(?:用户预估费用|预估费用|申请金额|预计金额|预计费用|预计总费用|预算|金额|费用)\s*[::]?\s*(?<value>\d+(?:\.\d+)?\s*(?:万|千|k|K)?\s*(?:元|块|人民币)?)/gu
|
||||
for (const match of source.matchAll(labelPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
const amountPattern = /(?<value>\d+(?:\.\d+)?\s*(?:(?:万|千|k|K)\s*(?:元|块|人民币)?|(?:元|块|人民币)))/gu
|
||||
for (const match of source.matchAll(amountPattern)) {
|
||||
candidates.push(normalizeApplicationAmountText(match.groups?.value || ''))
|
||||
}
|
||||
return uniqueApplicationCandidates(candidates)
|
||||
}
|
||||
|
||||
function uniqueApplicationCandidates(values) {
|
||||
return values
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.filter((item, index, list) => list.indexOf(item) === index)
|
||||
}
|
||||
|
||||
function resolveCurrentUserGrade(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.grade
|
||||
|| currentUser.employeeGrade
|
||||
|| currentUser.employee_grade
|
||||
|| currentUser.profileGrade
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolveCurrentUserDepartment(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.department
|
||||
|| currentUser.departmentName
|
||||
|| currentUser.department_name
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolveCurrentUserPosition(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.position
|
||||
|| currentUser.employeePosition
|
||||
|| currentUser.employee_position
|
||||
|| currentUser.jobTitle
|
||||
|| currentUser.job_title
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function resolveCurrentUserManagerName(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.managerName
|
||||
|| currentUser.manager_name
|
||||
|| currentUser.directManagerName
|
||||
|| currentUser.direct_manager_name
|
||||
|| currentUser.leaderName
|
||||
|| currentUser.leader_name
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
function parseApplicationDaysValue(value) {
|
||||
const match = String(value || '').match(/\d+/)
|
||||
const days = match ? Number(match[0]) : parseChineseNumber(value)
|
||||
return Number.isFinite(days) && days > 0 ? Math.max(1, Math.floor(days)) : 0
|
||||
}
|
||||
|
||||
function parseChineseNumber(value) {
|
||||
const digits = {
|
||||
一: 1,
|
||||
二: 2,
|
||||
两: 2,
|
||||
三: 3,
|
||||
四: 4,
|
||||
五: 5,
|
||||
六: 6,
|
||||
七: 7,
|
||||
八: 8,
|
||||
九: 9
|
||||
}
|
||||
const text = String(value || '').match(/[一二两三四五六七八九十]{1,3}/)?.[0] || ''
|
||||
if (!text) return 0
|
||||
if (text === '十') return 10
|
||||
if (text.includes('十')) {
|
||||
const [left, right] = text.split('十')
|
||||
const tens = left ? digits[left] || 0 : 1
|
||||
const ones = right ? digits[right] || 0 : 0
|
||||
return tens * 10 + ones
|
||||
}
|
||||
return digits[text] || 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, location = '', transportEstimate = null, time = '') {
|
||||
const mode = String(transportMode || '').trim()
|
||||
const estimate = transportEstimate || buildMockApplicationTransportEstimate({ transportMode: mode, location, time })
|
||||
if (!estimate) return APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT
|
||||
return estimate.basisText
|
||||
}
|
||||
|
||||
function buildTransportEstimateFromPolicyResult(result = {}, fields = {}) {
|
||||
const amount = parseMoneyNumber(result?.transport_estimated_amount)
|
||||
if (!amount || amount <= 0) return null
|
||||
const amountDisplay = formatPolicyMoney(amount)
|
||||
const mode = String(result?.transport_mode || fields.transportMode || '').trim()
|
||||
const origin = String(result?.transport_origin || '').trim()
|
||||
const destination = String(result?.transport_destination || result?.matched_city || fields.location || '').trim()
|
||||
const basis = String(result?.transport_estimate_basis || '').trim()
|
||||
const ruleName = String(result?.transport_estimate_rule_name || '交通费用预估表').trim()
|
||||
const routeText = [origin, destination].filter(Boolean).join('-')
|
||||
const modeText = mode ? `${mode}往返` : '往返'
|
||||
const routeModeText = routeText ? `${routeText}${modeText}` : modeText
|
||||
const displayBasis = routeModeText && basis.startsWith(routeModeText)
|
||||
? basis.slice(routeModeText.length).trim()
|
||||
: basis
|
||||
const basisSuffix = displayBasis ? `(${displayBasis})` : ''
|
||||
return {
|
||||
mode,
|
||||
amount,
|
||||
amountDisplay,
|
||||
routeType: '往返',
|
||||
origin,
|
||||
destination,
|
||||
queryDate: String(result?.travel_date || '').trim(),
|
||||
source: String(result?.transport_estimate_source || 'basic_rule_transport_estimate').trim(),
|
||||
confidence: String(result?.transport_estimate_confidence || 'basic_rule').trim(),
|
||||
basis,
|
||||
ruleCode: String(result?.transport_estimate_rule_code || '').trim(),
|
||||
ruleName,
|
||||
ruleVersion: String(result?.transport_estimate_rule_version || '').trim(),
|
||||
basisText: `当前尚未接通实时票务价格查询 API,无法获取当前实际票价;先按《${ruleName}》${routeModeText}${basisSuffix}暂估 ${amountDisplay}元用于申请阶段预算占用,最终报销以实际票据金额为准`
|
||||
}
|
||||
}
|
||||
|
||||
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, nextFields.location, null, nextFields.time)
|
||||
}
|
||||
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 = '', options = {}) {
|
||||
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 shortMonthDayRange = resolveShortMonthDayRange(text, options)
|
||||
if (shortMonthDayRange) {
|
||||
return shortMonthDayRange
|
||||
}
|
||||
|
||||
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 && endDate !== normalized ? `${normalized} 至 ${endDate}` : normalized
|
||||
}
|
||||
|
||||
function resolveApplicationTimeWithDefault(text, daysText = '', options = {}) {
|
||||
const resolvedTime = resolveApplicationTime(text, daysText, options)
|
||||
if (resolvedTime || !parseApplicationDaysValue(daysText)) {
|
||||
return resolvedTime
|
||||
}
|
||||
|
||||
const startDate = resolvePreviewToday(options)
|
||||
const endDate = buildEndDateFromDays(startDate, daysText)
|
||||
return endDate && endDate !== startDate ? `${startDate} 至 ${endDate}` : startDate
|
||||
}
|
||||
|
||||
function resolveApplicationLocation(text) {
|
||||
return resolveFirstMatch(text, [
|
||||
/(?:地点|业务地点|发生地点|目的地)\s*[::]\s*(?<value>[^。;;\n]+)/u,
|
||||
/(?:去|到|前往)(?<value>[\u4e00-\u9fa5,,、]{2,24}?)(?:出差|支撑|支持|部署|开会|培训|拜访|验收|项目|客户|。|\s|$)/u
|
||||
])
|
||||
}
|
||||
|
||||
function looksLikeTransportPromptText(text) {
|
||||
const compact = compactText(text)
|
||||
return /(?:还需要补充|当前还缺|缺少|请先补充|请选择|可以选择|可以选|打算怎么出行|怎么出行).{0,24}(?:出行方式|交通方式|交通工具)/u.test(compact)
|
||||
|| /(?:出行方式|交通方式|交通工具).{0,24}(?:待补充|缺失|请选择|可以选择|可以选)/u.test(compact)
|
||||
}
|
||||
|
||||
function resolveApplicationTransportMode(text) {
|
||||
const labeled = resolveFirstMatch(text, [
|
||||
/(?:出行方式|交通方式|交通工具|出行工具)\s*[::]\s*(?<value>[^。;;\n,,]+)/u
|
||||
])
|
||||
const labeledMode = normalizeTransportModeOption(labeled, '')
|
||||
if (labeledMode && !looksLikeTransportPromptText(labeled) && !/(?:请选择|可以选择|可以选)/u.test(String(labeled || ''))) {
|
||||
return labeledMode
|
||||
}
|
||||
const fullTextLooksLikePrompt = looksLikeTransportPromptText(text)
|
||||
const segments = String(text || '')
|
||||
.split(/[\n,,。;;]+/u)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
for (const segment of segments) {
|
||||
if (looksLikeTransportPromptText(segment)) continue
|
||||
const compactSegment = compactText(segment)
|
||||
if (
|
||||
fullTextLooksLikePrompt
|
||||
&& !/(?:申请|出差|去|到|赴|前往|服务|支撑|支持|部署|上线|实施|拜访|会议)/u.test(compactSegment)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
if (/高铁|动车|火车|铁路/.test(compactSegment)) return '火车'
|
||||
if (/飞机|机票|航班/.test(compactSegment)) return '飞机'
|
||||
if (/轮船|船票|客轮|渡轮/.test(compactSegment)) return '轮船'
|
||||
}
|
||||
if (fullTextLooksLikePrompt) return ''
|
||||
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]*$/u, '')
|
||||
.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{1,2}月)?\d{1,2}日?\s*(?:至|到|~|—|–|--|-)\s*(?:\d{1,2}月)?\d{1,2}日?/gu, '')
|
||||
.replace(/\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((item) => item && !isSystemGeneratedReasonText(item))
|
||||
return segments.find((item) => /服务|支撑|支持|部署|实施|验收|拜访|对接|沟通|培训|会议|采购|安装|维护|上线|调试|项目/.test(item)) || ''
|
||||
}
|
||||
|
||||
function isSystemGeneratedReasonText(value = '') {
|
||||
const compact = compactText(value)
|
||||
return compact.startsWith('小财管家继续执行')
|
||||
|| /请直接生成申请单核对结果|信息足够时生成申请单|入库或提交审批前|请直接生成报销核对结果/.test(compact)
|
||||
|| compact.startsWith('处理要求')
|
||||
|| compact.startsWith('已识别信息')
|
||||
|| compact.startsWith('用户已补充')
|
||||
|| /^(?:类型|申请类型|费用类型|报销类型)[::]?(?:差旅费用申请|交通费用申请|住宿费用申请|费用申请|差旅费|交通费|住宿费)?$/.test(compact)
|
||||
}
|
||||
|
||||
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)
|
||||
if (isSystemGeneratedReasonText(withoutContext)) return ''
|
||||
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}申请`
|
||||
}
|
||||
|
||||
export 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 resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', currentFields = {}) {
|
||||
const currentTransportMode = isApplicationPreviewValueProvided(currentFields.transportMode)
|
||||
? String(currentFields.transportMode).trim()
|
||||
: ''
|
||||
const explicitTransportMode = resolveApplicationTransportMode(rawText)
|
||||
if (!explicitTransportMode) {
|
||||
return currentTransportMode
|
||||
}
|
||||
|
||||
const ontologyTransportMode = normalizeTransportModeOption(ontologyFields.transportMode, '')
|
||||
if (ontologyTransportMode && ontologyTransportMode === explicitTransportMode) {
|
||||
return ontologyTransportMode
|
||||
}
|
||||
return currentTransportMode || explicitTransportMode
|
||||
}
|
||||
|
||||
function normalizeAmountFromOntology(fields = {}, fallback = '') {
|
||||
const numericAmount = Number(fields.amount || fields.policyTotalAmount || fields.reimbursementAmount || 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 normalizeTypedOntologyAmount(value, fallback = '') {
|
||||
const amount = Number(value || 0)
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
return `${amount}元`
|
||||
}
|
||||
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) => resolveApplicationFieldLabel(item, fields))
|
||||
}
|
||||
import {
|
||||
APPLICATION_POLICY_PENDING_TEXT,
|
||||
APPLICATION_PREVIEW_FIELD_DEFINITIONS,
|
||||
APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT,
|
||||
buildMissingFields,
|
||||
buildTransportEstimateFromPolicyResult,
|
||||
buildTransportPolicyText,
|
||||
ensureApplicationPolicyFields,
|
||||
formatDailyPolicyMoney,
|
||||
formatPolicyMoney,
|
||||
isApplicationPreviewValueProvided,
|
||||
isTravelApplicationType,
|
||||
normalizeAmountFromOntology,
|
||||
normalizeApplicationTypeLabel,
|
||||
normalizeTypedOntologyAmount,
|
||||
parseApplicationDaysValue,
|
||||
parseMoneyNumber,
|
||||
resolveApplicationAmount,
|
||||
resolveApplicationDays,
|
||||
resolveApplicationFieldLabel,
|
||||
resolveApplicationLocation,
|
||||
resolveApplicationReason,
|
||||
resolveApplicationSourceValidationIssues,
|
||||
resolveApplicationTimeWithDefault,
|
||||
resolveApplicationTransportMode,
|
||||
resolveApplicationTripDateParts,
|
||||
resolveApplicationType,
|
||||
resolveApplicationValidationIssues,
|
||||
resolveCurrentUserDepartment,
|
||||
resolveCurrentUserGrade,
|
||||
resolveCurrentUserManagerName,
|
||||
resolveCurrentUserPosition,
|
||||
resolveDaysFromDateRange,
|
||||
resolveModelRefinedTransportMode,
|
||||
resolveProvidedValue
|
||||
} from './expenseApplicationPreviewParsing.js'
|
||||
export {
|
||||
APPLICATION_PREVIEW_FIELD_DEFINITIONS,
|
||||
APPLICATION_TRANSPORT_MODE_OPTIONS,
|
||||
applicationDateRangesOverlap,
|
||||
normalizeTransportModeOption,
|
||||
resolveApplicationDateRange,
|
||||
resolveApplicationDaysFromDateRange,
|
||||
resolveApplicationTimeLabel,
|
||||
shouldRequireApplicationModelReview
|
||||
} from './expenseApplicationPreviewParsing.js'
|
||||
|
||||
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
|
||||
Reference in New Issue
Block a user