refactor: enforce 800 line source limits

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

View File

@@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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
}

View File

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

View 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('')
}

View 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('')
}

View File

@@ -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'
}
}

View 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
}
}

View File

@@ -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, '&#39;')
}
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)
}

View 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)
}

View 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
}

View File

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

View 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))
}

View 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
}
}