refactor: enforce 800 line source limits

This commit is contained in:
caoxiaozhu
2026-06-22 11:58:53 +08:00
parent 08a4fa3577
commit 6d33ba5742
150 changed files with 27413 additions and 23791 deletions

View File

@@ -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)