refactor: enforce 800 line source limits
This commit is contained in:
521
web/src/utils/aiAttachmentAssociationModel.js
Normal file
521
web/src/utils/aiAttachmentAssociationModel.js
Normal file
@@ -0,0 +1,521 @@
|
||||
import { buildDraftAssociationQueryPayload } from '../views/scripts/travelReimbursementExpenseQueryModel.js'
|
||||
|
||||
const CITY_NAMES = [
|
||||
'北京',
|
||||
'上海',
|
||||
'广州',
|
||||
'深圳',
|
||||
'武汉',
|
||||
'南京',
|
||||
'杭州',
|
||||
'成都',
|
||||
'重庆',
|
||||
'西安',
|
||||
'天津',
|
||||
'苏州',
|
||||
'长沙',
|
||||
'郑州',
|
||||
'青岛',
|
||||
'厦门',
|
||||
'宁波',
|
||||
'无锡',
|
||||
'合肥',
|
||||
'福州',
|
||||
'昆明',
|
||||
'大连',
|
||||
'沈阳',
|
||||
'济南',
|
||||
'哈尔滨',
|
||||
'长春',
|
||||
'南昌',
|
||||
'太原',
|
||||
'贵阳',
|
||||
'南宁',
|
||||
'石家庄',
|
||||
'兰州',
|
||||
'银川',
|
||||
'西宁',
|
||||
'海口',
|
||||
'拉萨'
|
||||
]
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function escapeHtml(value = '') {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function unique(values = []) {
|
||||
return Array.from(new Set(values.map((item) => String(item || '').trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
function collectOcrText(ocrDocuments = []) {
|
||||
return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
|
||||
.flatMap((document) => {
|
||||
const fields = Array.isArray(document?.document_fields)
|
||||
? document.document_fields.flatMap((field) => [field?.label, field?.value])
|
||||
: []
|
||||
return [document?.filename, document?.summary, document?.text, ...fields]
|
||||
})
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function normalizeDateToken(value) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const fullDateMatch = text.match(/(20\d{2})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
|
||||
if (fullDateMatch) {
|
||||
const [, year, month, day] = fullDateMatch
|
||||
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const shortDateMatch = text.match(/(\d{1,2})月(\d{1,2})/)
|
||||
if (shortDateMatch) {
|
||||
const [, month, day] = shortDateMatch
|
||||
return `${month.padStart(2, '0')}-${day.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractDateTokens(text) {
|
||||
const source = String(text || '')
|
||||
const matches = [
|
||||
...source.matchAll(/20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}/g),
|
||||
...source.matchAll(/\d{1,2}月\d{1,2}/g)
|
||||
]
|
||||
return unique(matches.map((match) => normalizeDateToken(match[0])))
|
||||
}
|
||||
|
||||
function extractCityTokens(text) {
|
||||
const compact = normalizeText(text)
|
||||
if (!compact) {
|
||||
return []
|
||||
}
|
||||
return CITY_NAMES.filter((city) => compact.includes(city))
|
||||
}
|
||||
|
||||
function collectFieldSignals(ocrDocuments = []) {
|
||||
return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
|
||||
.flatMap((document) => Array.isArray(document?.document_fields) ? document.document_fields : [])
|
||||
.filter((field) => {
|
||||
const label = normalizeText(field?.label)
|
||||
return /(日期|时间|发生|开票|出发|到达|起点|终点|地点|城市|路线|行程)/.test(label)
|
||||
})
|
||||
.map((field) => `${field?.label || ''} ${field?.value || ''}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export function collectAiAttachmentAssociationSignals(ocrDocuments = []) {
|
||||
const documentText = collectOcrText(ocrDocuments)
|
||||
const fieldText = collectFieldSignals(ocrDocuments)
|
||||
const combinedText = `${documentText} ${fieldText}`
|
||||
|
||||
return {
|
||||
text: combinedText,
|
||||
compactText: normalizeText(combinedText),
|
||||
dates: extractDateTokens(combinedText),
|
||||
cities: unique(extractCityTokens(combinedText))
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecordText(record = {}) {
|
||||
return [
|
||||
record.claimNo,
|
||||
record.expenseTypeLabel,
|
||||
record.statusLabel,
|
||||
record.reason,
|
||||
record.location,
|
||||
record.occurredAt,
|
||||
record.documentDate,
|
||||
record.summary
|
||||
].map((item) => String(item || '').trim()).filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
function scoreRecord(record = {}, signals = {}) {
|
||||
const recordText = buildRecordText(record)
|
||||
const compactRecordText = normalizeText(recordText)
|
||||
const recordDates = extractDateTokens(recordText)
|
||||
const recordCities = unique([...extractCityTokens(recordText), ...extractCityTokens(record.location)])
|
||||
const reasons = []
|
||||
let score = 0
|
||||
|
||||
const dateMatched = (signals.dates || []).some((date) => {
|
||||
if (!date) return false
|
||||
return recordDates.some((recordDate) => recordDate === date || recordDate.endsWith(date) || date.endsWith(recordDate))
|
||||
})
|
||||
if (dateMatched) {
|
||||
score += 4
|
||||
reasons.push('票据日期与报销单日期一致')
|
||||
}
|
||||
|
||||
const matchedCities = (signals.cities || []).filter((city) => compactRecordText.includes(city))
|
||||
if (matchedCities.length) {
|
||||
const cityScore = Math.min(4, matchedCities.length * 2)
|
||||
score += cityScore
|
||||
reasons.push(`地点或行程包含 ${matchedCities.join('、')}`)
|
||||
}
|
||||
|
||||
if (recordCities.length >= 2 && matchedCities.length >= 2) {
|
||||
score += 2
|
||||
reasons.push('票据往返城市与报销事由吻合')
|
||||
}
|
||||
|
||||
if (String(record.status || '').trim() === 'draft') {
|
||||
score += 1
|
||||
reasons.push('当前单据仍是可归集草稿')
|
||||
}
|
||||
|
||||
return {
|
||||
record,
|
||||
score,
|
||||
reasons
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiAttachmentAssociationMatch(claims = [], ocrDocuments = []) {
|
||||
const queryPayload = buildDraftAssociationQueryPayload(claims)
|
||||
const records = Array.isArray(queryPayload?.records) ? queryPayload.records : []
|
||||
const signals = collectAiAttachmentAssociationSignals(ocrDocuments)
|
||||
const rankedRecords = records
|
||||
.map((record) => scoreRecord(record, signals))
|
||||
.sort((left, right) => right.score - left.score)
|
||||
|
||||
const recommended = rankedRecords[0] || null
|
||||
const runnerUp = rankedRecords[1] || null
|
||||
const highConfidence = Boolean(
|
||||
recommended &&
|
||||
recommended.score >= 5 &&
|
||||
(!runnerUp || recommended.score - runnerUp.score >= 2)
|
||||
)
|
||||
|
||||
return {
|
||||
queryPayload,
|
||||
signals,
|
||||
rankedRecords,
|
||||
recommended,
|
||||
best: highConfidence ? recommended : null,
|
||||
highConfidence
|
||||
}
|
||||
}
|
||||
|
||||
function formatCandidateLine(candidate, index) {
|
||||
const record = candidate?.record || {}
|
||||
const claimNo = String(record.claimNo || '未编号').trim()
|
||||
const date = String(record.occurredAt || record.documentDate || '日期待补充').trim()
|
||||
const location = String(record.location || '地点待补充').trim()
|
||||
const reason = resolveRecordBusinessDescription(record) || '报销事项'
|
||||
return `${index + 1}. ${claimNo},${date},${location},${reason}`
|
||||
}
|
||||
|
||||
function wrapTrustedHtml(html = '') {
|
||||
return [
|
||||
'<!-- ai-trusted-html:start -->',
|
||||
html,
|
||||
'<!-- ai-trusted-html:end -->'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function renderAssociationField(label = '', value = '', options = {}) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
const fieldClass = options.wide ? ' ai-document-card__field--wide' : ''
|
||||
const valueClass = options.muted ? ' ai-attachment-association__muted' : ''
|
||||
return [
|
||||
`<div class="ai-document-card__field${fieldClass}">`,
|
||||
`<span class="ai-document-card__label">${escapeHtml(label)}</span>`,
|
||||
`<strong class="ai-document-card__value${valueClass}">${escapeHtml(text)}</strong>`,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function formatAttachmentNames(fileNames = []) {
|
||||
const names = unique(fileNames)
|
||||
if (!names.length) {
|
||||
return '已接收票据附件'
|
||||
}
|
||||
return `${names.length} 份:${names.slice(0, 2).join('、')}${names.length > 2 ? ' 等' : ''}`
|
||||
}
|
||||
|
||||
function formatSignalSummary(match = null) {
|
||||
const dates = Array.isArray(match?.signals?.dates) ? match.signals.dates : []
|
||||
const cities = Array.isArray(match?.signals?.cities) ? match.signals.cities : []
|
||||
return [
|
||||
dates.length ? `日期 ${dates.slice(0, 2).join('、')}` : '',
|
||||
cities.length ? `城市 ${cities.slice(0, 4).join('、')}` : ''
|
||||
].filter(Boolean).join(';') || '已识别票据关键信息'
|
||||
}
|
||||
|
||||
function isNoisyAssociationText(value = '') {
|
||||
const text = String(value || '').replace(/\s+/g, '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
if (!/[\u4e00-\u9fa5A-Za-z]/.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (/^[::;;,,.\-\d]+$/.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (/^[::;;]/.test(text) && /\d{6,}/.test(text)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeBusinessDescription(value = '') {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||||
return isNoisyAssociationText(text) ? '' : text
|
||||
}
|
||||
|
||||
function resolveRecordBusinessDescription(record = {}) {
|
||||
return (
|
||||
normalizeBusinessDescription(record.reason) ||
|
||||
normalizeBusinessDescription(record.summary)
|
||||
)
|
||||
}
|
||||
|
||||
function truncateOcrDetail(value = '', maxLength = 180) {
|
||||
const text = String(value || '').replace(/\s+/g, ' ').trim()
|
||||
if (!text || text.length <= maxLength) {
|
||||
return text
|
||||
}
|
||||
return `${text.slice(0, maxLength - 1)}…`
|
||||
}
|
||||
|
||||
function formatOcrDocumentDetail(document = {}) {
|
||||
const filename = String(document?.filename || '').trim()
|
||||
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
|
||||
const fieldText = fields
|
||||
.map((field) => {
|
||||
const label = String(field?.label || '').trim()
|
||||
const value = String(field?.value || '').trim()
|
||||
return label && value ? `${label}:${value}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 6)
|
||||
.join(',')
|
||||
const fallbackText = String(document?.summary || document?.text || '').trim()
|
||||
const detailText = truncateOcrDetail(fieldText || fallbackText)
|
||||
return [filename, detailText].filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
function formatOcrDocumentDetails(ocrDocuments = []) {
|
||||
return (Array.isArray(ocrDocuments) ? ocrDocuments : [])
|
||||
.map((document) => formatOcrDocumentDetail(document))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
.join(';')
|
||||
}
|
||||
|
||||
function renderAssociationCard({
|
||||
title = '',
|
||||
status = '',
|
||||
tone = 'is-warning',
|
||||
className = '',
|
||||
ariaLabel = '票据关联确认',
|
||||
fields = [],
|
||||
note = ''
|
||||
} = {}) {
|
||||
const normalizedClassName = String(className || '').trim()
|
||||
return wrapTrustedHtml([
|
||||
`<section class="ai-document-card-list" aria-label="${escapeHtml(ariaLabel)}">`,
|
||||
`<article class="ai-document-card ai-attachment-association-card${normalizedClassName ? ` ${escapeHtml(normalizedClassName)}` : ''} ${tone}">`,
|
||||
'<header class="ai-document-card__head">',
|
||||
`<strong class="ai-document-card__reason">${escapeHtml(title)}</strong>`,
|
||||
status ? `<span class="ai-document-card__status">${escapeHtml(status)}</span>` : '',
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
'<div class="ai-document-card__details ai-attachment-association__details">',
|
||||
fields.join(''),
|
||||
'</div>',
|
||||
note ? `<div class="ai-attachment-association__note">${escapeHtml(note)}</div>` : '',
|
||||
'</div>',
|
||||
'</article>',
|
||||
'</section>'
|
||||
].join(''))
|
||||
}
|
||||
|
||||
function renderOcrRecognitionCard({ attachmentLabel = '', signalSummary = '', ocrDetailSummary = '' } = {}) {
|
||||
return renderAssociationCard({
|
||||
title: '票据识别结果',
|
||||
status: '已识别',
|
||||
tone: 'is-pending',
|
||||
className: 'ai-ocr-recognition-card',
|
||||
ariaLabel: '票据 OCR 识别结果',
|
||||
fields: [
|
||||
renderAssociationField('本次附件', attachmentLabel),
|
||||
renderAssociationField('识别线索', signalSummary),
|
||||
ocrDetailSummary
|
||||
? renderAssociationField('票面识别', ocrDetailSummary, { wide: true, muted: true })
|
||||
: ''
|
||||
].filter(Boolean),
|
||||
note: '我会基于这些票面信息继续查询可关联单据。'
|
||||
})
|
||||
}
|
||||
|
||||
export function buildAiAttachmentAssociationMessage({
|
||||
match = null,
|
||||
fileNames = [],
|
||||
ocrDocuments = []
|
||||
} = {}) {
|
||||
const attachmentLabel = formatAttachmentNames(fileNames)
|
||||
const signalSummary = formatSignalSummary(match)
|
||||
const ocrDetailSummary = formatOcrDocumentDetails(ocrDocuments)
|
||||
const recognitionCard = renderOcrRecognitionCard({
|
||||
attachmentLabel,
|
||||
signalSummary,
|
||||
ocrDetailSummary
|
||||
})
|
||||
|
||||
if (!match?.rankedRecords?.length) {
|
||||
return [
|
||||
'我已先完成票据识别,识别结果如下。',
|
||||
recognitionCard,
|
||||
'我又查询了可关联单据,但当前没有查到可关联的报销草稿或待补充单据。',
|
||||
renderAssociationCard({
|
||||
title: '未找到可关联单据',
|
||||
status: '未归集',
|
||||
tone: 'is-warning',
|
||||
fields: [
|
||||
renderAssociationField('查询范围', '可归集草稿、待补充和退回单据', { wide: true }),
|
||||
renderAssociationField('处理建议', '暂不归集,避免把票据放错位置', { wide: true, muted: true })
|
||||
],
|
||||
note: '我先不做归集,避免把票据放错位置。'
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
if (match.highConfidence && match.best?.record) {
|
||||
const record = match.best.record
|
||||
const recordDescription = resolveRecordBusinessDescription(record)
|
||||
const reasons = match.best.reasons.length
|
||||
? match.best.reasons.join(';')
|
||||
: '票据信息与单据基础信息吻合'
|
||||
return [
|
||||
'我已先完成票据识别,识别结果如下。',
|
||||
recognitionCard,
|
||||
'我根据上述票面信息找到一张最可能关联的报销单。请确认是否自动归集:',
|
||||
renderAssociationCard({
|
||||
title: '可能关联单据',
|
||||
status: '待确认',
|
||||
tone: 'is-warning',
|
||||
fields: [
|
||||
renderAssociationField('推荐单据', record.claimNo),
|
||||
recordDescription
|
||||
? renderAssociationField('关联事项', recordDescription, { wide: true })
|
||||
: '',
|
||||
renderAssociationField('匹配依据', reasons, { wide: true, muted: true })
|
||||
].filter(Boolean),
|
||||
note: '确认后,我会把这些附件自动归集到该单据,并反馈处理结果。'
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
const candidates = match.rankedRecords.slice(0, 3).map(formatCandidateLine)
|
||||
return [
|
||||
'我已先完成票据识别,识别结果如下。',
|
||||
recognitionCard,
|
||||
'我根据上述票面信息查询到候选单据,但还不能放心自动锁定。',
|
||||
renderAssociationCard({
|
||||
title: '候选单据待核对',
|
||||
status: '需确认',
|
||||
tone: 'is-warning',
|
||||
fields: [
|
||||
renderAssociationField('候选单据', candidates.join(';'), { wide: true, muted: true })
|
||||
],
|
||||
note: '如果这就是要归集的单据,可直接点下方“确认自动关联”;不确定时也可以先查看单据。'
|
||||
})
|
||||
].filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
export function buildAiAttachmentAssociationResultMessage({
|
||||
claimNo = '',
|
||||
uploadedCount = 0,
|
||||
skippedCount = 0,
|
||||
fileNames = []
|
||||
} = {}) {
|
||||
const normalizedUploadedCount = Math.max(0, Number(uploadedCount || 0))
|
||||
const normalizedSkippedCount = Math.max(0, Number(skippedCount || 0))
|
||||
const done = normalizedUploadedCount > 0 && normalizedSkippedCount === 0
|
||||
return [
|
||||
done ? '已完成自动归集。' : '自动归集已处理完成,请留意未归集附件。',
|
||||
renderAssociationCard({
|
||||
title: done ? '票据已归集' : '票据归集结果',
|
||||
status: done ? '已完成' : '部分完成',
|
||||
tone: done ? 'is-success' : 'is-warning',
|
||||
fields: [
|
||||
renderAssociationField('关联单据', claimNo || '当前匹配单据'),
|
||||
renderAssociationField('归集结果', `${normalizedUploadedCount} 份成功${normalizedSkippedCount ? `,${normalizedSkippedCount} 份未归集` : ''}`),
|
||||
renderAssociationField('附件', formatAttachmentNames(fileNames), { wide: true })
|
||||
],
|
||||
note: done
|
||||
? '附件已经写入该报销单,可进入详情页继续核对。'
|
||||
: '部分附件没有找到可用明细项,请进入详情页手动核对。'
|
||||
})
|
||||
].join('\n\n')
|
||||
}
|
||||
|
||||
export function buildAiAttachmentAssociationActions(match = null, associationId = '', options = {}) {
|
||||
const record = match?.best?.record || match?.recommended?.record
|
||||
const actions = []
|
||||
if (options.includeOcrDetails) {
|
||||
actions.push({
|
||||
label: '查看附件信息',
|
||||
description: '展开本次上传附件的 OCR 识别明细。',
|
||||
icon: 'mdi mdi-file-search-outline',
|
||||
action_type: 'show_ai_attachment_ocr_details',
|
||||
payload: {}
|
||||
})
|
||||
}
|
||||
|
||||
if (!record?.claimNo && !record?.claimId) {
|
||||
return actions
|
||||
}
|
||||
|
||||
const payload = {
|
||||
claim_id: String(record.claimId || '').trim(),
|
||||
claim_no: String(record.claimNo || '').trim(),
|
||||
document_type: 'expense'
|
||||
}
|
||||
const normalizedAssociationId = String(associationId || '').trim()
|
||||
|
||||
if (payload.claim_id && normalizedAssociationId) {
|
||||
actions.push({
|
||||
label: '确认自动关联',
|
||||
description: '把本次票据自动归集到匹配单据。',
|
||||
icon: 'mdi mdi-link-variant',
|
||||
action_type: 'confirm_ai_attachment_association',
|
||||
payload: {
|
||||
...payload,
|
||||
association_id: normalizedAssociationId
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
actions.push({
|
||||
label: '查看单据',
|
||||
description: '先打开匹配单据核对详情。',
|
||||
icon: 'mdi mdi-open-in-new',
|
||||
action_type: 'open_application_detail',
|
||||
payload
|
||||
})
|
||||
|
||||
return actions
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import { renderLegacyAttachmentAssociationHtml } from './aiConversationLegacyAttachmentRenderer.js'
|
||||
import { parseTableRow, renderTable } from './aiConversationTableRenderer.js'
|
||||
|
||||
const ALLOWED_COLON_HEADING_TITLES = new Set([
|
||||
'基础信息识别结果',
|
||||
'报销测算参考',
|
||||
@@ -25,18 +28,6 @@ const DELETED_APPLICATION_DETAIL_HREF_PREFIX = '#ai-deleted-application-detail:'
|
||||
const DOCUMENT_DETAIL_HREF_PREFIX = '#ai-open-document-detail:'
|
||||
const TRUSTED_HTML_BLOCK_RE = /<!--\s*ai-trusted-html:start\s*-->\s*([\s\S]*?)\s*<!--\s*ai-trusted-html:end\s*-->/g
|
||||
const TRUSTED_HTML_PLACEHOLDER_PREFIX = 'AI_TRUSTED_HTML_BLOCK_'
|
||||
const DOCUMENT_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
submitted: '审批中',
|
||||
pending: '待处理',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
returned: '已退回',
|
||||
rejected: '已驳回',
|
||||
pending_payment: '待付款',
|
||||
paid: '已付款'
|
||||
}
|
||||
const TRUSTED_HTML_ALLOWED_TAGS = new Set([
|
||||
'section',
|
||||
'article',
|
||||
@@ -349,18 +340,6 @@ function parseImageLine(line = '') {
|
||||
}
|
||||
}
|
||||
|
||||
function parseTableRow(line = '') {
|
||||
const trimmed = String(line || '').trim()
|
||||
if (!trimmed.startsWith('|')) {
|
||||
return []
|
||||
}
|
||||
return trimmed
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
function splitLabelAndBody(rawText = '') {
|
||||
const text = String(rawText || '').trim()
|
||||
const strongMatch = text.match(/^\*\*([^*]+)\*\*[::]\s*(.*)$/u)
|
||||
@@ -510,174 +489,17 @@ function renderOrderedList(items = []) {
|
||||
].join('')
|
||||
}
|
||||
|
||||
function normalizeTableHeaderCell(value = '') {
|
||||
return String(value || '').replace(/\s+/g, '').trim()
|
||||
}
|
||||
|
||||
function findTableColumnIndex(normalizedHeader = [], labels = []) {
|
||||
return labels
|
||||
.map((label) => normalizedHeader.indexOf(label))
|
||||
.find((index) => index >= 0) ?? -1
|
||||
}
|
||||
|
||||
function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
|
||||
const columnIndex = findTableColumnIndex(normalizedHeader, labels)
|
||||
return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
|
||||
}
|
||||
|
||||
function hasMeaningfulTableValue(value = '') {
|
||||
const text = String(value || '').trim()
|
||||
return Boolean(text && text !== '-')
|
||||
}
|
||||
|
||||
function normalizeDocumentStatusLabel(status = '') {
|
||||
const text = String(status || '').trim()
|
||||
if (!text || text === '-') {
|
||||
return ''
|
||||
}
|
||||
return DOCUMENT_STATUS_LABELS[text.toLowerCase()] || text
|
||||
}
|
||||
|
||||
function resolveDocumentRecordTone(status = '', stage = '') {
|
||||
const normalizedStatus = normalizeDocumentStatusLabel(status)
|
||||
const text = `${normalizedStatus || String(status || '')} ${String(stage || '')}`.trim()
|
||||
if (/已删除|已驳回|驳回|拒绝|失败/.test(text)) {
|
||||
return 'is-danger'
|
||||
}
|
||||
if (/已审批|审批通过|已完成|已归档|已付款|已支付|可通过/.test(text)) {
|
||||
return 'is-success'
|
||||
}
|
||||
if (/草稿|待提交|待补充|已退回|退回/.test(text)) {
|
||||
return 'is-warning'
|
||||
}
|
||||
return 'is-pending'
|
||||
}
|
||||
|
||||
function isDocumentRecordTable(normalizedHeader = []) {
|
||||
return (
|
||||
normalizedHeader.includes('单据编号') &&
|
||||
normalizedHeader.includes('操作') &&
|
||||
normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
function renderDocumentCardField(label = '', value = '', options = {}) {
|
||||
if (!hasMeaningfulTableValue(value)) {
|
||||
return ''
|
||||
}
|
||||
const valueClass = options.valueClass ? ` ${options.valueClass}` : ''
|
||||
return [
|
||||
`<div class="ai-document-card__field${options.fieldClass ? ` ${options.fieldClass}` : ''}">`,
|
||||
`<span class="ai-document-card__label">${escapeHtml(label)}</span>`,
|
||||
`<strong class="ai-document-card__value${valueClass}">${renderInlineHtml(value)}</strong>`,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderDocumentCardAction(action = '') {
|
||||
if (!hasMeaningfulTableValue(action)) {
|
||||
return ''
|
||||
}
|
||||
const actionHtml = renderInlineHtml(action).replace(
|
||||
/class="ai-html-action-link\s+/g,
|
||||
'class="ai-html-action-link ai-document-card__action '
|
||||
)
|
||||
return [
|
||||
'<div class="ai-document-card__field ai-document-card__field--action">',
|
||||
'<span class="ai-document-card__label">操作</span>',
|
||||
actionHtml,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderDocumentRecordList(header = [], bodyRows = []) {
|
||||
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||
const items = bodyRows.map((row) => {
|
||||
const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
|
||||
const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
|
||||
const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间', '日期', '时间'])
|
||||
const location = resolveTableCell(row, normalizedHeader, ['地点', '目的地'])
|
||||
const amount = resolveTableCell(row, normalizedHeader, ['金额', '预计金额', '报销金额'])
|
||||
const status = normalizeDocumentStatusLabel(resolveTableCell(row, normalizedHeader, ['单据状态', '状态']))
|
||||
const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
|
||||
const reason = resolveTableCell(row, normalizedHeader, ['事由'])
|
||||
const action = resolveTableCell(row, normalizedHeader, ['操作'])
|
||||
const tone = resolveDocumentRecordTone(status, stage)
|
||||
const title = documentType || reason || documentNo || '单据详情'
|
||||
const summarySecondField = amount
|
||||
? renderDocumentCardField('金额', amount, { valueClass: 'ai-document-card__amount' })
|
||||
: renderDocumentCardField('当前节点', stage || status || '待确认')
|
||||
const summaryHtml = [
|
||||
renderDocumentCardField('日期', applyTime || '待补充'),
|
||||
summarySecondField
|
||||
].join('')
|
||||
const detailsHtml = [
|
||||
renderDocumentCardField('地点', location || '待补充'),
|
||||
renderDocumentCardField('单据编号', documentNo, { valueClass: 'ai-document-card__number' }),
|
||||
renderDocumentCardField('事由', reason || '待补充'),
|
||||
amount ? renderDocumentCardField('当前节点', stage || status || '待确认') : '',
|
||||
renderDocumentCardAction(action),
|
||||
renderDocumentCardField('单据类型', documentType)
|
||||
].join('')
|
||||
return [
|
||||
`<article class="ai-document-card ${tone}" role="listitem" aria-label="单据详情">`,
|
||||
'<header class="ai-document-card__head">',
|
||||
`<strong class="ai-document-card__reason">${renderInlineHtml(title)}</strong>`,
|
||||
hasMeaningfulTableValue(status) ? `<span class="ai-document-card__status">${renderInlineHtml(status)}</span>` : '',
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
summaryHtml ? `<div class="ai-document-card__summary">${summaryHtml}</div>` : '',
|
||||
'<div class="ai-document-card__details">',
|
||||
detailsHtml,
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</article>'
|
||||
].join('')
|
||||
}).filter(Boolean)
|
||||
|
||||
return [
|
||||
'<section class="ai-document-card-list" role="list" aria-label="单据结果">',
|
||||
...items,
|
||||
'</section>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderTable(lines = []) {
|
||||
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
|
||||
if (rows.length < 2) {
|
||||
return ''
|
||||
}
|
||||
const header = rows[0]
|
||||
const bodyRows = rows.slice(2)
|
||||
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||
if (isDocumentRecordTable(normalizedHeader)) {
|
||||
return renderDocumentRecordList(header, bodyRows)
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="ai-html-table-wrap">',
|
||||
'<table>',
|
||||
'<thead><tr>',
|
||||
...header.map((cell) => `<th>${renderInlineHtml(cell)}</th>`),
|
||||
'</tr></thead>',
|
||||
'<tbody>',
|
||||
...bodyRows.map((row) => [
|
||||
'<tr>',
|
||||
...header.map((_cell, index) => `<td>${renderInlineHtml(row[index] || '')}</td>`),
|
||||
'</tr>'
|
||||
].join('')),
|
||||
'</tbody>',
|
||||
'</table>',
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderCodeBlock(lines = []) {
|
||||
const code = lines.join('\n').replace(/\n$/, '')
|
||||
return `<pre class="ai-html-code"><code>${escapeHtml(code)}</code></pre>`
|
||||
}
|
||||
|
||||
export function renderAiConversationHtml(content = '') {
|
||||
const legacyAttachmentAssociationHtml = renderLegacyAttachmentAssociationHtml(content, { escapeHtml })
|
||||
if (legacyAttachmentAssociationHtml) {
|
||||
return legacyAttachmentAssociationHtml
|
||||
}
|
||||
|
||||
const extracted = extractTrustedHtmlBlocks(content)
|
||||
const normalized = normalizeConversationText(extracted.content)
|
||||
if (!normalized) {
|
||||
@@ -723,7 +545,7 @@ export function renderAiConversationHtml(content = '') {
|
||||
tableLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
blocks.push(renderTable(tableLines))
|
||||
blocks.push(renderTable(tableLines, { escapeHtml, renderInlineHtml }))
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
87
web/src/utils/aiConversationLegacyAttachmentRenderer.js
Normal file
87
web/src/utils/aiConversationLegacyAttachmentRenderer.js
Normal file
@@ -0,0 +1,87 @@
|
||||
function stripInlineMarkdownMarkers(value = '') {
|
||||
return String(value || '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/`/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function resolveLegacyAttachmentAssociationField(text = '', label = '') {
|
||||
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = String(text || '').match(new RegExp(`${escapedLabel}[::]\\s*([^\\n]+)`, 'u'))
|
||||
return stripInlineMarkdownMarkers(match?.[1] || '')
|
||||
}
|
||||
|
||||
function renderLegacyAttachmentAssociationField(label = '', value = '', options = {}, context = {}) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
`<div class="ai-document-card__field${options.wide ? ' ai-document-card__field--wide' : ''}">`,
|
||||
`<span class="ai-document-card__label">${context.escapeHtml(label)}</span>`,
|
||||
`<strong class="ai-document-card__value${options.muted ? ' ai-attachment-association__muted' : ''}">${context.escapeHtml(text)}</strong>`,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function isNoisyLegacyAssociationText(value = '') {
|
||||
const text = String(value || '').replace(/\s+/g, '').trim()
|
||||
if (!text) {
|
||||
return true
|
||||
}
|
||||
if (!/[\u4e00-\u9fa5A-Za-z]/.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (/^[::;;,,.\-\d]+$/.test(text)) {
|
||||
return true
|
||||
}
|
||||
if (/^[::;;]/.test(text) && /\d{6,}/.test(text)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeLegacyAssociationDescription(value = '') {
|
||||
const text = stripInlineMarkdownMarkers(value).replace(/\s+/g, ' ').trim()
|
||||
return isNoisyLegacyAssociationText(text) ? '' : text
|
||||
}
|
||||
|
||||
export function renderLegacyAttachmentAssociationHtml(content = '', options = {}) {
|
||||
const context = {
|
||||
escapeHtml: options.escapeHtml || ((item) => String(item || ''))
|
||||
}
|
||||
const text = String(content || '')
|
||||
if (!/我已先识别票据,并匹配到最可能的报销单/.test(text)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const attachment = resolveLegacyAttachmentAssociationField(text, '本次附件')
|
||||
const summary = resolveLegacyAttachmentAssociationField(text, '识别摘要')
|
||||
const claimNo = resolveLegacyAttachmentAssociationField(text, '推荐关联')
|
||||
const reason = normalizeLegacyAssociationDescription(resolveLegacyAttachmentAssociationField(text, '单据事项'))
|
||||
const basis = resolveLegacyAttachmentAssociationField(text, '匹配依据')
|
||||
|
||||
return [
|
||||
'<div class="ai-html-flow">',
|
||||
'<p class="ai-html-paragraph">我已先识别票据,并找到一张可能关联的报销单。请确认是否自动归集:</p>',
|
||||
'<section class="ai-document-card-list" aria-label="票据关联确认">',
|
||||
'<article class="ai-document-card ai-attachment-association-card is-warning">',
|
||||
'<header class="ai-document-card__head">',
|
||||
'<strong class="ai-document-card__reason">可能关联单据</strong>',
|
||||
'<span class="ai-document-card__status">待确认</span>',
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
'<div class="ai-document-card__details ai-attachment-association__details">',
|
||||
renderLegacyAttachmentAssociationField('推荐单据', claimNo, {}, context),
|
||||
renderLegacyAttachmentAssociationField('本次附件', attachment, {}, context),
|
||||
renderLegacyAttachmentAssociationField('识别摘要', summary, { wide: true, muted: true }, context),
|
||||
renderLegacyAttachmentAssociationField('关联事项', reason, { wide: true }, context),
|
||||
renderLegacyAttachmentAssociationField('匹配依据', basis, { wide: true, muted: true }, context),
|
||||
'</div>',
|
||||
'<div class="ai-attachment-association__note">如果这就是要归集的单据,可直接使用下方快捷按钮;不确定时也可以先查看单据。</div>',
|
||||
'</div>',
|
||||
'</article>',
|
||||
'</section>',
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
192
web/src/utils/aiConversationTableRenderer.js
Normal file
192
web/src/utils/aiConversationTableRenderer.js
Normal file
@@ -0,0 +1,192 @@
|
||||
const DOCUMENT_STATUS_LABELS = {
|
||||
draft: '草稿',
|
||||
submitted: '审批中',
|
||||
pending: '待处理',
|
||||
approved: '已审批',
|
||||
completed: '已完成',
|
||||
archived: '已归档',
|
||||
returned: '已退回',
|
||||
rejected: '已驳回',
|
||||
pending_payment: '待付款',
|
||||
paid: '已付款'
|
||||
}
|
||||
|
||||
function normalizeTableHeaderCell(value = '') {
|
||||
return String(value || '').replace(/\s+/g, '').trim()
|
||||
}
|
||||
|
||||
function findTableColumnIndex(normalizedHeader = [], labels = []) {
|
||||
return labels
|
||||
.map((label) => normalizedHeader.indexOf(label))
|
||||
.find((index) => index >= 0) ?? -1
|
||||
}
|
||||
|
||||
function resolveTableCell(row = [], normalizedHeader = [], labels = []) {
|
||||
const columnIndex = findTableColumnIndex(normalizedHeader, labels)
|
||||
return columnIndex >= 0 ? String(row[columnIndex] || '').trim() : ''
|
||||
}
|
||||
|
||||
function hasMeaningfulTableValue(value = '') {
|
||||
const text = String(value || '').trim()
|
||||
return Boolean(text && text !== '-')
|
||||
}
|
||||
|
||||
function normalizeDocumentStatusLabel(status = '') {
|
||||
const text = String(status || '').trim()
|
||||
if (!text || text === '-') {
|
||||
return ''
|
||||
}
|
||||
return DOCUMENT_STATUS_LABELS[text.toLowerCase()] || text
|
||||
}
|
||||
|
||||
function resolveDocumentRecordTone(status = '', stage = '') {
|
||||
const normalizedStatus = normalizeDocumentStatusLabel(status)
|
||||
const text = `${normalizedStatus || String(status || '')} ${String(stage || '')}`.trim()
|
||||
if (/已删除|已驳回|驳回|拒绝|失败/.test(text)) {
|
||||
return 'is-danger'
|
||||
}
|
||||
if (/已审批|审批通过|已完成|已归档|已付款|已支付|可通过/.test(text)) {
|
||||
return 'is-success'
|
||||
}
|
||||
if (/草稿|待提交|待补充|已退回|退回/.test(text)) {
|
||||
return 'is-warning'
|
||||
}
|
||||
return 'is-pending'
|
||||
}
|
||||
|
||||
function isDocumentRecordTable(normalizedHeader = []) {
|
||||
return (
|
||||
normalizedHeader.includes('单据编号') &&
|
||||
normalizedHeader.includes('操作') &&
|
||||
normalizedHeader.some((label) => ['单据类型', '申请时间', '单据状态', '状态', '当前节点', '事由'].includes(label))
|
||||
)
|
||||
}
|
||||
|
||||
function renderDocumentCardField(label = '', value = '', options = {}, context = {}) {
|
||||
if (!hasMeaningfulTableValue(value)) {
|
||||
return ''
|
||||
}
|
||||
const renderInlineHtml = context.renderInlineHtml || ((item) => String(item || ''))
|
||||
const valueClass = options.valueClass ? ` ${options.valueClass}` : ''
|
||||
return [
|
||||
`<div class="ai-document-card__field${options.fieldClass ? ` ${options.fieldClass}` : ''}">`,
|
||||
`<span class="ai-document-card__label">${context.escapeHtml(label)}</span>`,
|
||||
`<strong class="ai-document-card__value${valueClass}">${renderInlineHtml(value)}</strong>`,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderDocumentCardAction(action = '', context = {}) {
|
||||
if (!hasMeaningfulTableValue(action)) {
|
||||
return ''
|
||||
}
|
||||
const renderInlineHtml = context.renderInlineHtml || ((item) => String(item || ''))
|
||||
const actionHtml = renderInlineHtml(action).replace(
|
||||
/class="ai-html-action-link\s+/g,
|
||||
'class="ai-html-action-link ai-document-card__action '
|
||||
)
|
||||
return [
|
||||
'<div class="ai-document-card__field ai-document-card__field--action">',
|
||||
'<span class="ai-document-card__label">操作</span>',
|
||||
actionHtml,
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
function renderDocumentRecordList(header = [], bodyRows = [], context = {}) {
|
||||
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||
const items = bodyRows.map((row) => {
|
||||
const documentType = resolveTableCell(row, normalizedHeader, ['单据类型'])
|
||||
const documentNo = resolveTableCell(row, normalizedHeader, ['单据编号'])
|
||||
const applyTime = resolveTableCell(row, normalizedHeader, ['申请时间', '日期', '时间'])
|
||||
const location = resolveTableCell(row, normalizedHeader, ['地点', '目的地'])
|
||||
const amount = resolveTableCell(row, normalizedHeader, ['金额', '预计金额', '报销金额'])
|
||||
const status = normalizeDocumentStatusLabel(resolveTableCell(row, normalizedHeader, ['单据状态', '状态']))
|
||||
const stage = resolveTableCell(row, normalizedHeader, ['当前节点'])
|
||||
const reason = resolveTableCell(row, normalizedHeader, ['事由'])
|
||||
const action = resolveTableCell(row, normalizedHeader, ['操作'])
|
||||
const tone = resolveDocumentRecordTone(status, stage)
|
||||
const title = documentType || reason || documentNo || '单据详情'
|
||||
const summarySecondField = amount
|
||||
? renderDocumentCardField('金额', amount, { valueClass: 'ai-document-card__amount' }, context)
|
||||
: renderDocumentCardField('当前节点', stage || status || '待确认', {}, context)
|
||||
const summaryHtml = [
|
||||
renderDocumentCardField('日期', applyTime || '待补充', {}, context),
|
||||
summarySecondField
|
||||
].join('')
|
||||
const detailsHtml = [
|
||||
renderDocumentCardField('地点', location || '待补充', {}, context),
|
||||
renderDocumentCardField('单据编号', documentNo, { valueClass: 'ai-document-card__number' }, context),
|
||||
renderDocumentCardField('事由', reason || '待补充', {}, context),
|
||||
amount ? renderDocumentCardField('当前节点', stage || status || '待确认', {}, context) : '',
|
||||
renderDocumentCardAction(action, context),
|
||||
renderDocumentCardField('单据类型', documentType, {}, context)
|
||||
].join('')
|
||||
return [
|
||||
`<article class="ai-document-card ${tone}" role="listitem" aria-label="单据详情">`,
|
||||
'<header class="ai-document-card__head">',
|
||||
`<strong class="ai-document-card__reason">${context.renderInlineHtml(title)}</strong>`,
|
||||
hasMeaningfulTableValue(status) ? `<span class="ai-document-card__status">${context.renderInlineHtml(status)}</span>` : '',
|
||||
'</header>',
|
||||
'<div class="ai-document-card__body">',
|
||||
summaryHtml ? `<div class="ai-document-card__summary">${summaryHtml}</div>` : '',
|
||||
'<div class="ai-document-card__details">',
|
||||
detailsHtml,
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</article>'
|
||||
].join('')
|
||||
}).filter(Boolean)
|
||||
|
||||
return [
|
||||
'<section class="ai-document-card-list" role="list" aria-label="单据结果">',
|
||||
...items,
|
||||
'</section>'
|
||||
].join('')
|
||||
}
|
||||
|
||||
export function parseTableRow(line = '') {
|
||||
const trimmed = String(line || '').trim()
|
||||
if (!trimmed.startsWith('|')) {
|
||||
return []
|
||||
}
|
||||
return trimmed
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((cell) => cell.trim())
|
||||
}
|
||||
|
||||
export function renderTable(lines = [], options = {}) {
|
||||
const context = {
|
||||
escapeHtml: options.escapeHtml || ((item) => String(item || '')),
|
||||
renderInlineHtml: options.renderInlineHtml || ((item) => String(item || ''))
|
||||
}
|
||||
const rows = lines.map((line) => parseTableRow(line)).filter((row) => row.length)
|
||||
if (rows.length < 2) {
|
||||
return ''
|
||||
}
|
||||
const header = rows[0]
|
||||
const bodyRows = rows.slice(2)
|
||||
const normalizedHeader = header.map((cell) => normalizeTableHeaderCell(cell))
|
||||
if (isDocumentRecordTable(normalizedHeader)) {
|
||||
return renderDocumentRecordList(header, bodyRows, context)
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="ai-html-table-wrap">',
|
||||
'<table>',
|
||||
'<thead><tr>',
|
||||
...header.map((cell) => `<th>${context.renderInlineHtml(cell)}</th>`),
|
||||
'</tr></thead>',
|
||||
'<tbody>',
|
||||
...bodyRows.map((row) => [
|
||||
'<tr>',
|
||||
...header.map((_cell, index) => `<td>${context.renderInlineHtml(row[index] || '')}</td>`),
|
||||
'</tr>'
|
||||
].join('')),
|
||||
'</tbody>',
|
||||
'</table>',
|
||||
'</div>'
|
||||
].join('')
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export function buildAiDocumentDetailRequest(detailReference = {}) {
|
||||
documentType: isApplication ? 'application' : 'reimbursement',
|
||||
documentTypeCode: isApplication ? 'application' : 'reimbursement',
|
||||
detailLookupOnly: true,
|
||||
source: 'workbench',
|
||||
returnTo: 'workbench'
|
||||
source: 'ai-conversation',
|
||||
returnTo: 'conversation'
|
||||
}
|
||||
}
|
||||
|
||||
240
web/src/utils/aiDocumentQueryIntent.js
Normal file
240
web/src/utils/aiDocumentQueryIntent.js
Normal file
@@ -0,0 +1,240 @@
|
||||
import { compactText, formatDate, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ },
|
||||
{ label: '审批中', keys: ['submitted', 'pending'], pattern: /审批中|审核中|待审批|待审核|待审|已提交/ },
|
||||
{ label: '已审批', keys: ['approved'], pattern: /已审批|审批通过|已通过|已批准/ },
|
||||
{ label: '已完成', keys: ['completed'], pattern: /已完成|完成/ },
|
||||
{ label: '已归档', keys: ['archived'], pattern: /已归档|归档/ },
|
||||
{ label: '已退回', keys: ['returned'], pattern: /已退回|退回|待补充/ },
|
||||
{ label: '已驳回', keys: ['rejected'], pattern: /已驳回|驳回|拒绝/ },
|
||||
{ label: '待付款', keys: ['pending_payment'], pattern: /待付款|待支付/ },
|
||||
{ label: '已付款', keys: ['paid'], pattern: /已付款|已支付/ }
|
||||
]
|
||||
|
||||
const EXPENSE_TYPE_FILTERS = [
|
||||
{ label: '差旅费', codes: ['travel', 'travel_application'], pattern: /差旅|出差|差旅费|差旅费用/ },
|
||||
{ label: '交通费', codes: ['transport'], pattern: /交通|火车|机票|打车|出租|网约车/ },
|
||||
{ label: '住宿费', codes: ['hotel'], pattern: /住宿|酒店|宾馆/ },
|
||||
{ label: '业务招待费', codes: ['meal', 'entertainment'], pattern: /招待|餐饮|宴请|客户餐/ },
|
||||
{ label: '办公用品费', codes: ['office'], pattern: /办公|办公用品|采购/ },
|
||||
{ label: '会务费', codes: ['meeting'], pattern: /会务|会议/ },
|
||||
{ label: '培训费', codes: ['training'], pattern: /培训/ },
|
||||
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
|
||||
]
|
||||
|
||||
function resolveToday(options = {}) {
|
||||
return parseDate(options.today) || new Date()
|
||||
}
|
||||
|
||||
function lastDayOfMonth(year, month) {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
function buildMonthRange(year, month) {
|
||||
const normalizedMonth = String(month).padStart(2, '0')
|
||||
return {
|
||||
start: `${year}-${normalizedMonth}-01`,
|
||||
end: `${year}-${normalizedMonth}-${String(lastDayOfMonth(year, month)).padStart(2, '0')}`,
|
||||
label: `${year}年${month}月`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTimeRange(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
const today = resolveToday(options)
|
||||
const todayText = formatDate(today)
|
||||
|
||||
const explicitMonth = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?!\d{1,2})/)
|
||||
if (explicitMonth?.groups) {
|
||||
const year = Number(explicitMonth.groups.year || today.getUTCFullYear())
|
||||
const month = Number(explicitMonth.groups.month)
|
||||
if (month >= 1 && month <= 12) {
|
||||
return buildMonthRange(year, month)
|
||||
}
|
||||
}
|
||||
|
||||
const explicitRange = text.match(/(?:(?<year>20\d{2})年?)?(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?(?:至|到|~|-|—|–)(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日?/)
|
||||
if (explicitRange?.groups) {
|
||||
const year = Number(explicitRange.groups.year || today.getUTCFullYear())
|
||||
const startMonth = Number(explicitRange.groups.startMonth)
|
||||
const endMonth = Number(explicitRange.groups.endMonth || startMonth)
|
||||
const start = `${year}-${String(startMonth).padStart(2, '0')}-${String(explicitRange.groups.startDay).padStart(2, '0')}`
|
||||
const end = `${year}-${String(endMonth).padStart(2, '0')}-${String(explicitRange.groups.endDay).padStart(2, '0')}`
|
||||
return { start, end, label: `${start} 至 ${end}` }
|
||||
}
|
||||
|
||||
const explicitDay = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?<day>\d{1,2})日?/)
|
||||
if (explicitDay?.groups) {
|
||||
const year = Number(explicitDay.groups.year || today.getUTCFullYear())
|
||||
const value = `${year}-${String(explicitDay.groups.month).padStart(2, '0')}-${String(explicitDay.groups.day).padStart(2, '0')}`
|
||||
return { start: value, end: value, label: value }
|
||||
}
|
||||
|
||||
if (/今天|今日/.test(text)) {
|
||||
return { start: todayText, end: todayText, label: '今天' }
|
||||
}
|
||||
|
||||
if (/昨天/.test(text)) {
|
||||
const date = new Date(today.getTime())
|
||||
date.setUTCDate(date.getUTCDate() - 1)
|
||||
const value = formatDate(date)
|
||||
return { start: value, end: value, label: '昨天' }
|
||||
}
|
||||
|
||||
if (/本月|这个月|当月/.test(text)) {
|
||||
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (/上月|上个月/.test(text)) {
|
||||
const date = new Date(today.getTime())
|
||||
date.setUTCMonth(date.getUTCMonth() - 1)
|
||||
return buildMonthRange(date.getUTCFullYear(), date.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (/今年|本年/.test(text)) {
|
||||
const year = today.getUTCFullYear()
|
||||
return { start: `${year}-01-01`, end: `${year}-12-31`, label: `${year}年` }
|
||||
}
|
||||
|
||||
const recent = text.match(/近(?<days>\d{1,3})天/)
|
||||
if (recent?.groups?.days) {
|
||||
const days = Math.max(1, Number(recent.groups.days))
|
||||
const start = new Date(today.getTime())
|
||||
start.setUTCDate(start.getUTCDate() - days + 1)
|
||||
return { start: formatDate(start), end: todayText, label: `近${days}天` }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDocumentType(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/申请单|申请类单据|申请类/.test(text)) {
|
||||
return 'application'
|
||||
}
|
||||
if (/报销单|报销类单据|报销类/.test(text)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function resolveStatusFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return STATUS_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function resolveExpenseTypeFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function normalizeAmountText(value = '') {
|
||||
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
||||
if (!matched) {
|
||||
return null
|
||||
}
|
||||
const amount = Number(matched[0])
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
function resolveAmountFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
const range = text.match(/金额(?:在|为)?(?<min>\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|–)(?<max>\d+(?:\.\d+)?)(?:元)?/)
|
||||
if (range?.groups) {
|
||||
const min = normalizeAmountText(range.groups.min)
|
||||
const max = normalizeAmountText(range.groups.max)
|
||||
if (min !== null && max !== null) {
|
||||
return {
|
||||
min: Math.min(min, max),
|
||||
max: Math.max(min, max),
|
||||
label: `${Math.min(min, max)}-${Math.max(min, max)}元`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minMatch = text.match(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|
||||
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以上/)
|
||||
if (minMatch?.groups?.amount) {
|
||||
const min = normalizeAmountText(minMatch.groups.amount)
|
||||
return min === null ? null : { min, max: null, label: `不少于${min}元` }
|
||||
}
|
||||
|
||||
const maxMatch = text.match(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|
||||
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以下/)
|
||||
if (maxMatch?.groups?.amount) {
|
||||
const max = normalizeAmountText(maxMatch.groups.amount)
|
||||
return max === null ? null : { min: null, max, label: `不超过${max}元` }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeKeywordCandidate(value = '') {
|
||||
return normalizeText(value)
|
||||
.replace(/^(的|是|为|包含|含有)+/u, '')
|
||||
.replace(/(相关的?|有关的?|单据|单子|申请单|报销单|审核单|审批单|有哪些|有吗|查询|查看)$/u, '')
|
||||
.replace(/的$/u, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function resolveKeywordFilter(prompt) {
|
||||
const text = normalizeText(prompt)
|
||||
const compact = compactText(prompt)
|
||||
const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[::\s]*(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
|
||||
const relatedMatch = compact.match(/(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})相关(?:的)?(?:单据|单子|申请单|报销单|审核单|审批单)/u)
|
||||
const keyword = normalizeKeywordCandidate(explicitMatch?.groups?.keyword || relatedMatch?.groups?.keyword || '')
|
||||
if (!keyword || /^(现在|当前|哪些|审核|审批|申请|报销|单据|单子)$/u.test(keyword)) {
|
||||
return null
|
||||
}
|
||||
return { keyword, label: keyword }
|
||||
}
|
||||
|
||||
function resolveSource(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
|
||||
return {
|
||||
source: 'approval',
|
||||
sourceLabel: '待我审核的单据'
|
||||
}
|
||||
}
|
||||
if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
|
||||
return {
|
||||
source: 'mine',
|
||||
sourceLabel: '我的单据'
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: 'accessible',
|
||||
sourceLabel: '我可见的单据'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
|
||||
return null
|
||||
}
|
||||
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
||||
return null
|
||||
}
|
||||
const source = resolveSource(text)
|
||||
const documentType = resolveDocumentType(text)
|
||||
const statusFilter = resolveStatusFilter(text)
|
||||
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
||||
const keywordFilter = resolveKeywordFilter(prompt)
|
||||
const amountFilter = resolveAmountFilter(text)
|
||||
return {
|
||||
...source,
|
||||
documentType,
|
||||
documentTypeLabel: documentType === 'application'
|
||||
? '申请单'
|
||||
: documentType === 'reimbursement'
|
||||
? '报销单'
|
||||
: '全部单据',
|
||||
timeRange: resolveTimeRange(text, options),
|
||||
statusFilter,
|
||||
expenseTypeFilter,
|
||||
keywordFilter,
|
||||
amountFilter
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { extractExpenseClaimItems } from '../services/reimbursements.js'
|
||||
import { buildAiDocumentDetailHref } from './aiDocumentDetailReference.js'
|
||||
import { isApplicationDocumentNo } from './documentClassification.js'
|
||||
import { compactText, normalizeDateText, normalizeText, parseDate } from './aiDocumentQueryText.js'
|
||||
|
||||
export { resolveAiDocumentQueryIntent } from './aiDocumentQueryIntent.js'
|
||||
|
||||
const DOCUMENT_QUERY_LIMIT = 8
|
||||
|
||||
@@ -33,29 +36,6 @@ const TYPE_LABELS = {
|
||||
other: '其他费用'
|
||||
}
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ label: '草稿', keys: ['draft'], pattern: /草稿|未提交/ },
|
||||
{ label: '审批中', keys: ['submitted', 'pending'], pattern: /审批中|审核中|待审批|待审核|待审|已提交/ },
|
||||
{ label: '已审批', keys: ['approved'], pattern: /已审批|审批通过|已通过|已批准/ },
|
||||
{ label: '已完成', keys: ['completed'], pattern: /已完成|完成/ },
|
||||
{ label: '已归档', keys: ['archived'], pattern: /已归档|归档/ },
|
||||
{ label: '已退回', keys: ['returned'], pattern: /已退回|退回|待补充/ },
|
||||
{ label: '已驳回', keys: ['rejected'], pattern: /已驳回|驳回|拒绝/ },
|
||||
{ label: '待付款', keys: ['pending_payment'], pattern: /待付款|待支付/ },
|
||||
{ label: '已付款', keys: ['paid'], pattern: /已付款|已支付/ }
|
||||
]
|
||||
|
||||
const EXPENSE_TYPE_FILTERS = [
|
||||
{ label: '差旅费', codes: ['travel', 'travel_application'], pattern: /差旅|出差|差旅费|差旅费用/ },
|
||||
{ label: '交通费', codes: ['transport'], pattern: /交通|火车|机票|打车|出租|网约车/ },
|
||||
{ label: '住宿费', codes: ['hotel'], pattern: /住宿|酒店|宾馆/ },
|
||||
{ label: '业务招待费', codes: ['meal', 'entertainment'], pattern: /招待|餐饮|宴请|客户餐/ },
|
||||
{ label: '办公用品费', codes: ['office'], pattern: /办公|办公用品|采购/ },
|
||||
{ label: '会务费', codes: ['meeting'], pattern: /会务|会议/ },
|
||||
{ label: '培训费', codes: ['training'], pattern: /培训/ },
|
||||
{ label: '软件服务费', codes: ['software'], pattern: /软件|服务费|订阅/ }
|
||||
]
|
||||
|
||||
const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
@@ -63,10 +43,6 @@ const MONEY_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
function resolveStatusDisplayLabel(value = '') {
|
||||
const text = normalizeText(value)
|
||||
if (!text) {
|
||||
@@ -84,252 +60,6 @@ function escapeHtml(value = '') {
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return normalizeText(value).replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
function normalizeDateText(value) {
|
||||
const text = normalizeText(value)
|
||||
const matched = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
|
||||
if (!matched) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
matched[1],
|
||||
String(matched[2]).padStart(2, '0'),
|
||||
String(matched[3]).padStart(2, '0')
|
||||
].join('-')
|
||||
}
|
||||
|
||||
function parseDate(value) {
|
||||
const text = normalizeDateText(value)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(`${text}T00:00:00Z`)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function resolveToday(options = {}) {
|
||||
return parseDate(options.today) || new Date()
|
||||
}
|
||||
|
||||
function lastDayOfMonth(year, month) {
|
||||
return new Date(Date.UTC(year, month, 0)).getUTCDate()
|
||||
}
|
||||
|
||||
function buildMonthRange(year, month) {
|
||||
const normalizedMonth = String(month).padStart(2, '0')
|
||||
return {
|
||||
start: `${year}-${normalizedMonth}-01`,
|
||||
end: `${year}-${normalizedMonth}-${String(lastDayOfMonth(year, month)).padStart(2, '0')}`,
|
||||
label: `${year}年${month}月`
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTimeRange(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
const today = resolveToday(options)
|
||||
const todayText = formatDate(today)
|
||||
|
||||
const explicitMonth = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?!\d{1,2})/)
|
||||
if (explicitMonth?.groups) {
|
||||
const year = Number(explicitMonth.groups.year || today.getUTCFullYear())
|
||||
const month = Number(explicitMonth.groups.month)
|
||||
if (month >= 1 && month <= 12) {
|
||||
return buildMonthRange(year, month)
|
||||
}
|
||||
}
|
||||
|
||||
const explicitRange = text.match(/(?:(?<year>20\d{2})年?)?(?<startMonth>\d{1,2})月(?<startDay>\d{1,2})日?(?:至|到|~|-|—|–)(?:(?<endMonth>\d{1,2})月)?(?<endDay>\d{1,2})日?/)
|
||||
if (explicitRange?.groups) {
|
||||
const year = Number(explicitRange.groups.year || today.getUTCFullYear())
|
||||
const startMonth = Number(explicitRange.groups.startMonth)
|
||||
const endMonth = Number(explicitRange.groups.endMonth || startMonth)
|
||||
const start = `${year}-${String(startMonth).padStart(2, '0')}-${String(explicitRange.groups.startDay).padStart(2, '0')}`
|
||||
const end = `${year}-${String(endMonth).padStart(2, '0')}-${String(explicitRange.groups.endDay).padStart(2, '0')}`
|
||||
return { start, end, label: `${start} 至 ${end}` }
|
||||
}
|
||||
|
||||
const explicitDay = text.match(/(?:(?<year>20\d{2})年?)?(?<month>\d{1,2})月(?<day>\d{1,2})日?/)
|
||||
if (explicitDay?.groups) {
|
||||
const year = Number(explicitDay.groups.year || today.getUTCFullYear())
|
||||
const value = `${year}-${String(explicitDay.groups.month).padStart(2, '0')}-${String(explicitDay.groups.day).padStart(2, '0')}`
|
||||
return { start: value, end: value, label: value }
|
||||
}
|
||||
|
||||
if (/今天|今日/.test(text)) {
|
||||
return { start: todayText, end: todayText, label: '今天' }
|
||||
}
|
||||
|
||||
if (/昨天/.test(text)) {
|
||||
const date = new Date(today.getTime())
|
||||
date.setUTCDate(date.getUTCDate() - 1)
|
||||
const value = formatDate(date)
|
||||
return { start: value, end: value, label: '昨天' }
|
||||
}
|
||||
|
||||
if (/本月|这个月|当月/.test(text)) {
|
||||
return buildMonthRange(today.getUTCFullYear(), today.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (/上月|上个月/.test(text)) {
|
||||
const date = new Date(today.getTime())
|
||||
date.setUTCMonth(date.getUTCMonth() - 1)
|
||||
return buildMonthRange(date.getUTCFullYear(), date.getUTCMonth() + 1)
|
||||
}
|
||||
|
||||
if (/今年|本年/.test(text)) {
|
||||
const year = today.getUTCFullYear()
|
||||
return { start: `${year}-01-01`, end: `${year}-12-31`, label: `${year}年` }
|
||||
}
|
||||
|
||||
const recent = text.match(/近(?<days>\d{1,3})天/)
|
||||
if (recent?.groups?.days) {
|
||||
const days = Math.max(1, Number(recent.groups.days))
|
||||
const start = new Date(today.getTime())
|
||||
start.setUTCDate(start.getUTCDate() - days + 1)
|
||||
return { start: formatDate(start), end: todayText, label: `近${days}天` }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveDocumentType(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/申请单|申请类单据|申请类/.test(text)) {
|
||||
return 'application'
|
||||
}
|
||||
if (/报销单|报销类单据|报销类/.test(text)) {
|
||||
return 'reimbursement'
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
function resolveStatusFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return STATUS_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function resolveExpenseTypeFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
return EXPENSE_TYPE_FILTERS.find((item) => item.pattern.test(text)) || null
|
||||
}
|
||||
|
||||
function normalizeAmountText(value = '') {
|
||||
const matched = compactText(value).replace(/,/g, '').match(/-?\d+(?:\.\d+)?/)
|
||||
if (!matched) {
|
||||
return null
|
||||
}
|
||||
const amount = Number(matched[0])
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
function resolveAmountFilter(prompt) {
|
||||
const text = compactText(prompt)
|
||||
const range = text.match(/金额(?:在|为)?(?<min>\d+(?:\.\d+)?)(?:元)?(?:到|至|~|-|—|–)(?<max>\d+(?:\.\d+)?)(?:元)?/)
|
||||
if (range?.groups) {
|
||||
const min = normalizeAmountText(range.groups.min)
|
||||
const max = normalizeAmountText(range.groups.max)
|
||||
if (min !== null && max !== null) {
|
||||
return {
|
||||
min: Math.min(min, max),
|
||||
max: Math.max(min, max),
|
||||
label: `${Math.min(min, max)}-${Math.max(min, max)}元`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const minMatch = text.match(/(?:金额)?(?:大于|超过|高于|不少于|不低于|>=|以上)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|
||||
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以上/)
|
||||
if (minMatch?.groups?.amount) {
|
||||
const min = normalizeAmountText(minMatch.groups.amount)
|
||||
return min === null ? null : { min, max: null, label: `不少于${min}元` }
|
||||
}
|
||||
|
||||
const maxMatch = text.match(/(?:金额)?(?:小于|低于|少于|不超过|<=|以下)(?<amount>\d+(?:\.\d+)?)(?:元)?/)
|
||||
|| text.match(/(?<amount>\d+(?:\.\d+)?)(?:元)?以下/)
|
||||
if (maxMatch?.groups?.amount) {
|
||||
const max = normalizeAmountText(maxMatch.groups.amount)
|
||||
return max === null ? null : { min: null, max, label: `不超过${max}元` }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeKeywordCandidate(value = '') {
|
||||
return normalizeText(value)
|
||||
.replace(/^(的|是|为|包含|含有)+/u, '')
|
||||
.replace(/(相关的?|有关的?|单据|单子|申请单|报销单|审核单|审批单|有哪些|有吗|查询|查看)$/u, '')
|
||||
.replace(/的$/u, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function resolveKeywordFilter(prompt) {
|
||||
const text = normalizeText(prompt)
|
||||
const compact = compactText(prompt)
|
||||
const explicitMatch = text.match(/(?:关于|有关|包含|含有|关键词|关键字|事由(?:是|为|包含|含有)?)[::\s]*(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})/u)
|
||||
const relatedMatch = compact.match(/(?<keyword>[\u4e00-\u9fa5A-Za-z0-9_-]{2,32})相关(?:的)?(?:单据|单子|申请单|报销单|审核单|审批单)/u)
|
||||
const keyword = normalizeKeywordCandidate(explicitMatch?.groups?.keyword || relatedMatch?.groups?.keyword || '')
|
||||
if (!keyword || /^(现在|当前|哪些|审核|审批|申请|报销|单据|单子)$/u.test(keyword)) {
|
||||
return null
|
||||
}
|
||||
return { keyword, label: keyword }
|
||||
}
|
||||
|
||||
function resolveSource(prompt) {
|
||||
const text = compactText(prompt)
|
||||
if (/审核单|审批单|待审|待审核|待审批|我审批|我审核/.test(text)) {
|
||||
return {
|
||||
source: 'approval',
|
||||
sourceLabel: '待我审核的单据'
|
||||
}
|
||||
}
|
||||
if (/我名下|我发起|我提交|我创建|我的申请|我的报销/.test(text)) {
|
||||
return {
|
||||
source: 'mine',
|
||||
sourceLabel: '我的单据'
|
||||
}
|
||||
}
|
||||
return {
|
||||
source: 'accessible',
|
||||
sourceLabel: '我可见的单据'
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiDocumentQueryIntent(prompt, options = {}) {
|
||||
const text = compactText(prompt)
|
||||
if (!text || !/(单据|单子|申请单|报销单|审核单|审批单|待审|待审批|待审核)/.test(text)) {
|
||||
return null
|
||||
}
|
||||
if (/(发起|创建|新增|填写|生成|申请出差|我要报销|提交).*(单据|申请单|报销单)?/.test(text)) {
|
||||
return null
|
||||
}
|
||||
const source = resolveSource(text)
|
||||
const documentType = resolveDocumentType(text)
|
||||
const statusFilter = resolveStatusFilter(text)
|
||||
const expenseTypeFilter = resolveExpenseTypeFilter(text)
|
||||
const keywordFilter = resolveKeywordFilter(prompt)
|
||||
const amountFilter = resolveAmountFilter(text)
|
||||
return {
|
||||
...source,
|
||||
documentType,
|
||||
documentTypeLabel: documentType === 'application'
|
||||
? '申请单'
|
||||
: documentType === 'reimbursement'
|
||||
? '报销单'
|
||||
: '全部单据',
|
||||
timeRange: resolveTimeRange(text, options),
|
||||
statusFilter,
|
||||
expenseTypeFilter,
|
||||
keywordFilter,
|
||||
amountFilter
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDocumentNo(claim = {}) {
|
||||
return normalizeText(claim.claim_no || claim.claimNo || claim.documentNo || claim.id || claim.claim_id)
|
||||
}
|
||||
|
||||
33
web/src/utils/aiDocumentQueryText.js
Normal file
33
web/src/utils/aiDocumentQueryText.js
Normal file
@@ -0,0 +1,33 @@
|
||||
export function normalizeText(value) {
|
||||
return String(value ?? '').trim()
|
||||
}
|
||||
|
||||
export function compactText(value) {
|
||||
return normalizeText(value).replace(/\s+/g, '')
|
||||
}
|
||||
|
||||
export function normalizeDateText(value) {
|
||||
const text = normalizeText(value)
|
||||
const matched = text.match(/^(\d{4})[-/.年](\d{1,2})[-/.月](\d{1,2})/)
|
||||
if (!matched) {
|
||||
return ''
|
||||
}
|
||||
return [
|
||||
matched[1],
|
||||
String(matched[2]).padStart(2, '0'),
|
||||
String(matched[3]).padStart(2, '0')
|
||||
].join('-')
|
||||
}
|
||||
|
||||
export function parseDate(value) {
|
||||
const text = normalizeDateText(value)
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const date = new Date(`${text}T00:00:00Z`)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function formatDate(date) {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
361
web/src/utils/documentCenterViewModel.js
Normal file
361
web/src/utils/documentCenterViewModel.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import { countClaimRisks, resolveArchiveRiskTone } from './archiveCenterListFilters.js'
|
||||
import { isNewDocument } from './documentCenterNewState.js'
|
||||
import { isArchivedDocumentRow } from './documentCenterRows.js'
|
||||
import { sortDocumentRowsByLatestTime } from './documentCenterSort.js'
|
||||
import {
|
||||
extractDateText,
|
||||
formatDocumentListTime,
|
||||
resolveDocumentSortTime,
|
||||
resolveDocumentStayTimeDisplay
|
||||
} from './documentCenterTime.js'
|
||||
import { normalizeRequestForUi } from './requestViewModel.js'
|
||||
|
||||
export const DOCUMENT_TYPE_ALL = 'all'
|
||||
export const DOCUMENT_TYPE_APPLICATION = 'application'
|
||||
export const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
|
||||
export const SCENE_ALL = 'all'
|
||||
export const DOCUMENT_SCOPE_ALL = '全部'
|
||||
export const DOCUMENT_SCOPE_APPLICATION = '申请单'
|
||||
export const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
|
||||
export const DOCUMENT_SCOPE_REVIEW = '审核单'
|
||||
export const DOCUMENT_SCOPE_ARCHIVE = '归档'
|
||||
export const scopeTabs = [
|
||||
DOCUMENT_SCOPE_ALL,
|
||||
DOCUMENT_SCOPE_APPLICATION,
|
||||
DOCUMENT_SCOPE_REIMBURSEMENT,
|
||||
DOCUMENT_SCOPE_REVIEW,
|
||||
DOCUMENT_SCOPE_ARCHIVE
|
||||
]
|
||||
export const DOCUMENT_LOADING_MIN_VISIBLE_MS = 720
|
||||
export const DOCUMENT_CENTER_QUERY_KEYS = new Set([
|
||||
'dc_page',
|
||||
'dc_page_size',
|
||||
'dc_scope',
|
||||
'dc_status',
|
||||
'dc_doc_type',
|
||||
'dc_scene',
|
||||
'dc_q',
|
||||
'dc_start',
|
||||
'dc_end'
|
||||
])
|
||||
export const riskLevelTabs = ['全部', '高风险', '中风险', '低风险', '无风险']
|
||||
export const RISK_TONE_META = {
|
||||
high: { label: '高风险', tone: 'high' },
|
||||
medium: { label: '中风险', tone: 'medium' },
|
||||
low: { label: '低风险', tone: 'low' },
|
||||
none: { label: '无风险', tone: 'none' }
|
||||
}
|
||||
export const FILTER_CONFIG_BY_SCOPE = {
|
||||
[DOCUMENT_SCOPE_ALL]: {
|
||||
searchPlaceholder: '搜索单号、事项、费用场景...',
|
||||
sceneFallbackLabel: '单据场景',
|
||||
dateLabel: '单据时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: true
|
||||
},
|
||||
[DOCUMENT_SCOPE_APPLICATION]: {
|
||||
searchPlaceholder: '搜索申请单号、申请事项、申请场景...',
|
||||
sceneFallbackLabel: '申请场景',
|
||||
dateLabel: '申请时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_REIMBURSEMENT]: {
|
||||
searchPlaceholder: '搜索报销单号、报销事由、费用场景...',
|
||||
sceneFallbackLabel: '费用场景',
|
||||
dateLabel: '报销时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_REVIEW]: {
|
||||
searchPlaceholder: '搜索审核单号、事项、当前环节...',
|
||||
sceneFallbackLabel: '审核场景',
|
||||
dateLabel: '审核时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
},
|
||||
[DOCUMENT_SCOPE_ARCHIVE]: {
|
||||
searchPlaceholder: '搜索归档单号、事项、费用场景...',
|
||||
sceneFallbackLabel: '归档场景',
|
||||
dateLabel: '归档时间',
|
||||
statusTitle: '风险等级',
|
||||
statusTabs: riskLevelTabs,
|
||||
showDocumentType: false
|
||||
}
|
||||
}
|
||||
export const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
|
||||
export const pageSizeValues = pageSizeOptions.map((item) => item.value)
|
||||
export const documentTypeOptions = [
|
||||
{ value: DOCUMENT_TYPE_ALL, label: '单据类型' },
|
||||
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
|
||||
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
|
||||
]
|
||||
|
||||
export function routeQueryEquals(left, right) {
|
||||
const leftEntries = Object.entries(left || {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.join(',') : String(value ?? '')
|
||||
])
|
||||
const rightEntries = Object.entries(right || {}).map(([key, value]) => [
|
||||
key,
|
||||
Array.isArray(value) ? value.join(',') : String(value ?? '')
|
||||
])
|
||||
if (leftEntries.length !== rightEntries.length) return false
|
||||
const rightMap = new Map(rightEntries)
|
||||
return leftEntries.every(([key, value]) => rightMap.get(key) === value)
|
||||
}
|
||||
|
||||
export function buildDocumentRow(request, options = {}) {
|
||||
const normalized = normalizeRequestForUi(request)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
const archived = Boolean(options.archived)
|
||||
const source = options.source || 'owned'
|
||||
const statusGroup = resolveStatusGroup(normalized, archived)
|
||||
const statusLabel = archived ? resolveArchivedStatusLabel(normalized) : resolveStatusLabel(normalized, statusGroup)
|
||||
const riskMeta = buildDocumentRiskMeta(normalized, options.currentUser)
|
||||
const documentNo = normalized.documentNo || normalized.id || normalized.claimId || '待生成'
|
||||
const claimId = normalized.claimId || normalized.id || documentNo
|
||||
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
|
||||
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
|
||||
const createdSortTime = resolveDocumentSortTime(createdAtSource)
|
||||
const updatedSortTime = resolveDocumentSortTime(updatedAtSource)
|
||||
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
|
||||
const documentTypeLabel =
|
||||
normalized.documentTypeLabel
|
||||
|| (documentTypeCode === DOCUMENT_TYPE_APPLICATION ? '申请单' : '报销单')
|
||||
const initiatorName = String(
|
||||
normalized.person
|
||||
|| normalized.employeeName
|
||||
|| normalized.profileName
|
||||
|| normalized.applicant
|
||||
|| request?.employee_name
|
||||
|| request?.employeeName
|
||||
|| request?.person
|
||||
|| ''
|
||||
).trim() || '待补充'
|
||||
|
||||
return {
|
||||
...normalized,
|
||||
rawRequest: request,
|
||||
documentKey: `${source}:${claimId || documentNo}`,
|
||||
documentTypeCode,
|
||||
documentTypeLabel,
|
||||
claimId,
|
||||
documentNo,
|
||||
initiatorName,
|
||||
node: archived ? resolveArchivedDocumentNode(normalized, documentTypeCode) : (normalized.node || normalized.workflowNode || '待提交'),
|
||||
statusGroup,
|
||||
statusLabel,
|
||||
statusTone: archived ? 'archived' : resolveStatusTone(normalized, statusGroup),
|
||||
riskTone: riskMeta.tone,
|
||||
riskLabel: riskMeta.label,
|
||||
riskCount: riskMeta.count,
|
||||
riskTags: riskMeta.tags,
|
||||
source,
|
||||
archived,
|
||||
createdAtDisplay: formatDocumentListTime(createdAtSource),
|
||||
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
|
||||
isNewDocument: archived
|
||||
? false
|
||||
: isNewDocument({ ...normalized, source, claimId, documentNo }, options.viewedDocumentKeys || []),
|
||||
updatedAtDisplay: formatDocumentListTime(updatedAtSource),
|
||||
createdSortTime,
|
||||
updatedSortTime,
|
||||
sortTime: Math.max(createdSortTime, updatedSortTime)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildDocumentRiskMeta(row, currentUser = null) {
|
||||
const riskFlags = resolveDocumentRiskFlags(row)
|
||||
const riskSummary = row?.riskSummary || row?.risk
|
||||
// 列表风险标签按当前查看者可见性过滤,与详情页口径一致。
|
||||
const viewerOptions = currentUser ? { request: row || {}, currentUser } : null
|
||||
const count = countClaimRisks(riskFlags, riskSummary, viewerOptions)
|
||||
if (!count) {
|
||||
const meta = RISK_TONE_META.none
|
||||
return {
|
||||
...meta,
|
||||
count: 0,
|
||||
tags: [{ ...meta }]
|
||||
}
|
||||
}
|
||||
|
||||
const tone = resolveArchiveRiskTone(riskFlags, riskSummary, viewerOptions)
|
||||
const meta = RISK_TONE_META[tone] || RISK_TONE_META.medium
|
||||
return {
|
||||
...meta,
|
||||
count,
|
||||
tags: [{ tone: meta.tone, label: `${meta.label} ${count}项` }]
|
||||
}
|
||||
}
|
||||
|
||||
export function filterDocumentRows(rows, filters = {}) {
|
||||
const keyword = String(filters.keyword || '').trim().toLowerCase()
|
||||
return sortDocumentRowsByLatestTime((rows || []).filter((row) => {
|
||||
const matchesKeyword = !keyword || [
|
||||
row.documentNo,
|
||||
row.documentTypeLabel,
|
||||
row.typeLabel,
|
||||
row.initiatorName,
|
||||
row.reason,
|
||||
row.node,
|
||||
row.statusLabel,
|
||||
row.riskLabel
|
||||
].filter(Boolean).join('').toLowerCase().includes(keyword)
|
||||
|
||||
const matchesDocumentType =
|
||||
!filters.showDocumentTypeFilter
|
||||
|| filters.activeDocumentType === DOCUMENT_TYPE_ALL
|
||||
|| row.documentTypeCode === filters.activeDocumentType
|
||||
|
||||
const matchesScene = filters.activeScene === SCENE_ALL || row.typeCode === filters.activeScene
|
||||
const matchesRiskLevel = matchesRiskLevelTab(row, filters.activeStatusTab, filters.activeScopeTab)
|
||||
const matchesDateRange = matchesAppliedDateRange(row, filters.appliedStart, filters.appliedEnd)
|
||||
|
||||
return matchesKeyword && matchesDocumentType && matchesScene && matchesRiskLevel && matchesDateRange
|
||||
}))
|
||||
}
|
||||
|
||||
export function matchesRiskLevelTab(row, tab, activeScopeTab = DOCUMENT_SCOPE_ALL) {
|
||||
if (activeScopeTab !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow(row)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (tab === '全部') return true
|
||||
if (tab === '高风险') return row.riskTone === 'high'
|
||||
if (tab === '中风险') return row.riskTone === 'medium'
|
||||
if (tab === '低风险') return row.riskTone === 'low'
|
||||
if (tab === '无风险') return row.riskTone === 'none'
|
||||
return true
|
||||
}
|
||||
|
||||
export function matchesAppliedDateRange(row, start, end) {
|
||||
if (!start || !end) {
|
||||
return true
|
||||
}
|
||||
|
||||
const date = extractDateText(row.updatedAt || row.submittedAt || row.createdAt || row.applyTime)
|
||||
return Boolean(date) && date >= start && date <= end
|
||||
}
|
||||
|
||||
export function mergeDocumentRows(rows) {
|
||||
const rowMap = new Map()
|
||||
|
||||
rows.filter(Boolean).forEach((row) => {
|
||||
const key = row.claimId || row.documentNo || row.documentKey
|
||||
const current = rowMap.get(key)
|
||||
if (!current || resolveSourcePriority(row) >= resolveSourcePriority(current)) {
|
||||
rowMap.set(key, row)
|
||||
}
|
||||
})
|
||||
|
||||
return sortDocumentRowsByLatestTime(Array.from(rowMap.values()))
|
||||
}
|
||||
|
||||
export function hasDocumentCenterActiveFilters(filters = {}) {
|
||||
return Boolean(
|
||||
String(filters.listKeyword || '').trim()
|
||||
|| filters.activeStatusTab !== '全部'
|
||||
|| (filters.showDocumentTypeFilter && filters.activeDocumentType !== DOCUMENT_TYPE_ALL)
|
||||
|| filters.activeScene !== SCENE_ALL
|
||||
|| filters.appliedStart
|
||||
|| filters.appliedEnd
|
||||
)
|
||||
}
|
||||
|
||||
export function buildDocumentCenterEmptyState(options = {}) {
|
||||
const filtered = Boolean(options.hasActiveFilters)
|
||||
const activeScopeTab = options.activeScopeTab || DOCUMENT_SCOPE_ALL
|
||||
if (
|
||||
activeScopeTab === DOCUMENT_SCOPE_APPLICATION
|
||||
|| options.activeDocumentType === DOCUMENT_TYPE_APPLICATION
|
||||
) {
|
||||
return {
|
||||
eyebrow: '申请单',
|
||||
title: '当前还没有申请单数据',
|
||||
desc: '费用申请功能接入后,差旅、会务、办公采购等前置申请会统一汇总到这里。',
|
||||
icon: 'mdi mdi-file-sign-outline',
|
||||
actionLabel: '',
|
||||
actionIcon: '',
|
||||
tone: 'theme',
|
||||
artLabel: 'APPLY',
|
||||
tips: ['申请、报销、审批与归档统一在此查看', '申请批准后可继续发起报销']
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eyebrow: filtered ? '筛选结果为空' : '单据中心',
|
||||
title: filtered ? '没有符合当前条件的单据' : `“${activeScopeTab}”里暂时没有单据`,
|
||||
desc: filtered
|
||||
? '可以清空当前分类下的筛选条件后再看看。'
|
||||
: '当前视角暂无可展示单据,可以切换其他视角或发起一笔报销。',
|
||||
icon: filtered ? 'mdi mdi-magnify-scan' : 'mdi mdi-file-document-multiple-outline',
|
||||
actionLabel: '',
|
||||
actionIcon: '',
|
||||
tone: 'theme',
|
||||
artLabel: filtered ? 'FILTER' : 'DOCS',
|
||||
tips: ['单据中心已接入当前报销单据', '归档视角会同步已归档数据']
|
||||
}
|
||||
}
|
||||
|
||||
function resolveArchivedDocumentNode(normalized, documentTypeCode) {
|
||||
if (documentTypeCode === DOCUMENT_TYPE_APPLICATION) {
|
||||
return '申请归档'
|
||||
}
|
||||
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款') {
|
||||
return '已付款'
|
||||
}
|
||||
return normalized.node || normalized.workflowNode || '财务归档'
|
||||
}
|
||||
|
||||
function resolveArchivedStatusLabel(normalized) {
|
||||
if (normalized.status === 'paid' || normalized.approvalStatus === '已付款' || normalized.node === '已付款') {
|
||||
return '已付款'
|
||||
}
|
||||
return '已归档'
|
||||
}
|
||||
|
||||
function resolveStatusGroup(row, archived) {
|
||||
if (archived) return 'completed'
|
||||
if (row.approvalKey === 'draft') return 'draft'
|
||||
if (row.approvalKey === 'supplement' && row.status === 'returned') return 'pending_submit'
|
||||
if (row.approvalKey === 'supplement') return 'supplement'
|
||||
if (row.approvalKey === 'pending_payment') return 'pending_payment'
|
||||
if (row.approvalKey === 'in_progress') return 'in_progress'
|
||||
if (row.approvalKey === 'completed') return 'completed'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function resolveStatusLabel(row, statusGroup) {
|
||||
if (statusGroup === 'pending_submit') return '待提交'
|
||||
if (statusGroup === 'pending_payment') return '待付款'
|
||||
return row.approval || row.approvalStatus || '处理中'
|
||||
}
|
||||
|
||||
function resolveStatusTone(row, statusGroup) {
|
||||
if (statusGroup === 'pending_submit') return 'warning'
|
||||
return row.approvalTone || 'neutral'
|
||||
}
|
||||
|
||||
function resolveDocumentRiskFlags(row) {
|
||||
if (Array.isArray(row?.riskFlags)) {
|
||||
return row.riskFlags
|
||||
}
|
||||
if (Array.isArray(row?.risk_flags_json)) {
|
||||
return row.risk_flags_json
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function resolveSourcePriority(row) {
|
||||
if (row.archived) return 3
|
||||
if (row.source === 'approval') return 2
|
||||
return 1
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
742
web/src/utils/expenseApplicationPreviewParsing.js
Normal file
742
web/src/utils/expenseApplicationPreviewParsing.js
Normal file
@@ -0,0 +1,742 @@
|
||||
import { buildMockApplicationTransportEstimate } 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 = ['火车', '飞机', '轮船']
|
||||
|
||||
export const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
|
||||
export const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动预估交通费用'
|
||||
|
||||
export function resolveApplicationTimeLabel(applicationType = '') {
|
||||
const label = String(applicationType || '').trim()
|
||||
if (/差旅|出差/.test(label)) return '出发时间'
|
||||
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
|
||||
return '申请时间'
|
||||
}
|
||||
|
||||
export function resolveApplicationFieldLabel(item, fields = {}) {
|
||||
if (item.key === 'time') {
|
||||
return resolveApplicationTimeLabel(fields.applicationType)
|
||||
}
|
||||
return item.label
|
||||
}
|
||||
|
||||
export function isTravelApplicationType(applicationType = '') {
|
||||
return /差旅|出差/.test(String(applicationType || '').trim())
|
||||
}
|
||||
|
||||
export 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}`
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export 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'
|
||||
}
|
||||
|
||||
export 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()
|
||||
}
|
||||
|
||||
export 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 '费用申请'
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export function resolveCurrentUserGrade(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.grade
|
||||
|| currentUser.employeeGrade
|
||||
|| currentUser.employee_grade
|
||||
|| currentUser.profileGrade
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
export function resolveCurrentUserDepartment(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.department
|
||||
|| currentUser.departmentName
|
||||
|| currentUser.department_name
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
export function resolveCurrentUserPosition(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.position
|
||||
|| currentUser.employeePosition
|
||||
|| currentUser.employee_position
|
||||
|| currentUser.jobTitle
|
||||
|| currentUser.job_title
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
export function resolveCurrentUserManagerName(currentUser = {}) {
|
||||
return String(
|
||||
currentUser.managerName
|
||||
|| currentUser.manager_name
|
||||
|| currentUser.directManagerName
|
||||
|| currentUser.direct_manager_name
|
||||
|| currentUser.leaderName
|
||||
|| currentUser.leader_name
|
||||
|| ''
|
||||
).trim()
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function parseMoneyNumber(value) {
|
||||
const normalized = String(value ?? '').replace(/[^\d.-]/g, '')
|
||||
const amount = Number(normalized)
|
||||
return Number.isFinite(amount) ? amount : null
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export function formatDailyPolicyMoney(value) {
|
||||
const display = formatPolicyMoney(value)
|
||||
return display ? `${display}元/天` : APPLICATION_POLICY_PENDING_TEXT
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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}元用于申请阶段预算占用,最终报销以实际票据金额为准`
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function isApplicationPreviewValueProvided(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
return Boolean(normalized) && !['待测算', '待补充', '未知'].includes(normalized)
|
||||
}
|
||||
|
||||
export function resolveProvidedValue(value, fallback = '') {
|
||||
return isApplicationPreviewValueProvided(value) ? String(value).trim() : fallback
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
|
||||
export function normalizeTypedOntologyAmount(value, fallback = '') {
|
||||
const amount = Number(value || 0)
|
||||
if (Number.isFinite(amount) && amount > 0) {
|
||||
return `${amount}元`
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
export 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))
|
||||
}
|
||||
124
web/src/utils/expenseClaimAttachmentSync.js
Normal file
124
web/src/utils/expenseClaimAttachmentSync.js
Normal file
@@ -0,0 +1,124 @@
|
||||
function normalizeAttachmentMatchName(value) {
|
||||
const fileName = String(value || '')
|
||||
.trim()
|
||||
.split(/[\\/]/)
|
||||
.filter(Boolean)
|
||||
.pop() || ''
|
||||
return fileName
|
||||
.toLowerCase()
|
||||
.replace(/[^\w.\-\u4e00-\u9fff]+/g, '_')
|
||||
.replace(/^[_\.]+|[_\.]+$/g, '')
|
||||
}
|
||||
|
||||
function isSystemGeneratedExpenseItem(item = {}) {
|
||||
const itemType = String(item?.itemType || item?.item_type || '').trim()
|
||||
return Boolean(
|
||||
item?.isSystemGenerated ||
|
||||
item?.is_system_generated ||
|
||||
itemType === 'travel_allowance'
|
||||
)
|
||||
}
|
||||
|
||||
function findCreatedAttachmentItem(items = [], usedItemIds = new Set()) {
|
||||
return (Array.isArray(items) ? items : []).find((item) => {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
const itemType = String(item?.itemType || item?.item_type || '').trim()
|
||||
return (
|
||||
itemId &&
|
||||
!usedItemIds.has(itemId) &&
|
||||
!invoiceId &&
|
||||
itemType !== 'travel_allowance' &&
|
||||
!item?.isSystemGenerated &&
|
||||
!item?.is_system_generated
|
||||
)
|
||||
}) || null
|
||||
}
|
||||
|
||||
export async function syncExpenseClaimFilesToDraft({
|
||||
claimId = '',
|
||||
files = [],
|
||||
fetchExpenseClaimDetail,
|
||||
createExpenseClaimItem,
|
||||
uploadExpenseClaimItemAttachment
|
||||
} = {}) {
|
||||
const normalizedClaimId = String(claimId || '').trim()
|
||||
const safeFiles = Array.isArray(files) ? files : []
|
||||
if (!normalizedClaimId || !safeFiles.length || typeof uploadExpenseClaimItemAttachment !== 'function') {
|
||||
return { uploadedCount: 0, skippedCount: safeFiles.length, uploadedFileNames: [], skippedFileNames: safeFiles.map((file) => file?.name || '') }
|
||||
}
|
||||
if (typeof fetchExpenseClaimDetail !== 'function') {
|
||||
throw new Error('缺少单据详情查询服务,暂时无法自动归集附件。')
|
||||
}
|
||||
|
||||
const claim = await fetchExpenseClaimDetail(normalizedClaimId)
|
||||
const items = Array.isArray(claim?.items) ? claim.items : []
|
||||
const exactMatchBuckets = new Map()
|
||||
const normalizedMatchBuckets = new Map()
|
||||
const placeholderQueue = []
|
||||
const emptyAttachmentQueue = []
|
||||
const usedItemIds = new Set()
|
||||
const uploadedFileNames = []
|
||||
|
||||
for (const item of items) {
|
||||
const itemId = String(item?.id || '').trim()
|
||||
const invoiceId = String(item?.invoiceId || item?.invoice_id || '').trim()
|
||||
if (!itemId) continue
|
||||
|
||||
if (!invoiceId && !isSystemGeneratedExpenseItem(item)) {
|
||||
emptyAttachmentQueue.push(item)
|
||||
continue
|
||||
}
|
||||
if (!invoiceId || invoiceId.includes('/')) {
|
||||
continue
|
||||
}
|
||||
|
||||
placeholderQueue.push(item)
|
||||
const exactBucket = exactMatchBuckets.get(invoiceId) || []
|
||||
exactBucket.push(item)
|
||||
exactMatchBuckets.set(invoiceId, exactBucket)
|
||||
|
||||
const normalizedInvoiceName = normalizeAttachmentMatchName(invoiceId)
|
||||
if (normalizedInvoiceName) {
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizedInvoiceName) || []
|
||||
normalizedBucket.push(item)
|
||||
normalizedMatchBuckets.set(normalizedInvoiceName, normalizedBucket)
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of safeFiles) {
|
||||
const exactBucket = exactMatchBuckets.get(file.name) || []
|
||||
const nextExactMatch = exactBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const normalizedBucket = normalizedMatchBuckets.get(normalizeAttachmentMatchName(file.name)) || []
|
||||
const nextNormalizedMatch = normalizedBucket.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const fallbackMatch = placeholderQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
const emptyFallbackMatch = emptyAttachmentQueue.find((item) => !usedItemIds.has(String(item?.id || '').trim()))
|
||||
let targetItem = nextExactMatch || nextNormalizedMatch || fallbackMatch || emptyFallbackMatch
|
||||
let targetItemId = String(targetItem?.id || '').trim()
|
||||
|
||||
if (!targetItemId && typeof createExpenseClaimItem === 'function') {
|
||||
const updatedClaim = await createExpenseClaimItem(normalizedClaimId, {})
|
||||
targetItem = findCreatedAttachmentItem(updatedClaim?.items, usedItemIds)
|
||||
targetItemId = String(targetItem?.id || '').trim()
|
||||
}
|
||||
if (!targetItemId) {
|
||||
continue
|
||||
}
|
||||
|
||||
usedItemIds.add(targetItemId)
|
||||
await uploadExpenseClaimItemAttachment(normalizedClaimId, targetItemId, file)
|
||||
uploadedFileNames.push(String(file?.name || '').trim())
|
||||
}
|
||||
|
||||
const uploadedSet = new Set(uploadedFileNames)
|
||||
const skippedFileNames = safeFiles
|
||||
.map((file) => String(file?.name || '').trim())
|
||||
.filter((name) => !uploadedSet.has(name))
|
||||
|
||||
return {
|
||||
uploadedCount: uploadedFileNames.length,
|
||||
skippedCount: Math.max(0, safeFiles.length - uploadedFileNames.length),
|
||||
uploadedFileNames,
|
||||
skippedFileNames
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user