import { buildDraftAssociationQueryPayload } from '../views/scripts/travelReimbursementExpenseQueryModel.js' const CITY_NAMES = [ '北京', '上海', '广州', '深圳', '武汉', '南京', '杭州', '成都', '重庆', '西安', '天津', '苏州', '长沙', '郑州', '青岛', '厦门', '宁波', '无锡', '合肥', '福州', '昆明', '大连', '沈阳', '济南', '哈尔滨', '长春', '南昌', '太原', '贵阳', '南宁', '石家庄', '兰州', '银川', '西宁', '海口', '拉萨' ] function normalizeText(value) { return String(value || '') .trim() .replace(/\s+/g, '') } function escapeHtml(value = '') { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, ''') } 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 [ '', html, '' ].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 [ `
`, `${escapeHtml(label)}`, `${escapeHtml(text)}`, '
' ].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([ `
`, `
`, '
', `${escapeHtml(title)}`, status ? `${escapeHtml(status)}` : '', '
', '
', '
', fields.join(''), '
', note ? `
${escapeHtml(note)}
` : '', '
', '
', '
' ].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 }