Files
X-Financial/web/src/views/scripts/travelReimbursementAttachmentModel.js

675 lines
21 KiB
JavaScript
Raw Normal View History

import {
buildReviewSlotMap,
resolveDocumentTypeLabel,
resolveExpenseTypeCode
} from './travelReimbursementReviewModel.js'
import { normalizeExpenseQueryPayload } from './travelReimbursementExpenseQueryModel.js'
const SCENARIO_LABELS = {
expense: '报销',
accounts_receivable: '应收',
accounts_payable: '应付',
knowledge: '知识',
unknown: '通用'
}
const INTENT_LABELS = {
query: '查询',
explain: '解释',
compare: '对比',
risk_check: '风险检查',
draft: '信息核对',
operate: '动作请求'
}
function resolveStatusLabel(status) {
if (status === 'succeeded') return '已完成'
if (status === 'blocked') return '已阻断'
return '处理中'
}
function resolveStatusTone(status) {
if (status === 'succeeded') return 'success'
if (status === 'blocked') return 'warning'
return 'note'
}
export const MAX_ATTACHMENTS = 10
export const MAX_OCR_DOCUMENTS = 10
export const VISIBLE_ATTACHMENT_CHIPS = 2
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = [] } = {}) {
const names = (Array.isArray(fileNames) ? fileNames : [])
.map((item) => String(item || '').trim())
.filter(Boolean)
const attachmentLine = names.length
? `本次待归集附件:${names.length} 份(${names.join('、')}`
: '本次待归集附件:待识别'
return [
'当前这笔报销信息还没有保存为草稿。',
'',
'如果继续上传票据,我需要先把当前已识别的信息保存成一张草稿单据,再识别并归集本次附件。',
'',
attachmentLine,
'',
'',
`如果 **[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})**,我会先保存这笔未保存单据,再把此次上传的附件归集到该单据。`
].join('\n').trim()
}
export function normalizeOcrDocuments(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
filename: item.filename,
summary: item.summary,
text: String(item.text || ''),
avg_score: Number(item.avg_score || 0),
line_count: Number(item.line_count || 0),
document_type: String(item.document_type || 'other').trim() || 'other',
document_type_label: String(item.document_type_label || '').trim(),
scene_code: String(item.scene_code || 'other').trim() || 'other',
scene_label: String(item.scene_label || '').trim(),
preview_kind: String(item.preview_kind || '').trim(),
preview_data_url: String(item.preview_data_url || '').trim(),
preview_url: String(item.preview_url || '').trim(),
receipt_id: String(item.receipt_id || item.receiptId || '').trim(),
receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(),
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
document_fields: Array.isArray(item.document_fields)
? item.document_fields
.map((field) => ({
key: String(field?.key || '').trim(),
label: String(field?.label || '').trim(),
value: String(field?.value || '').trim()
}))
.filter((field) => field.key && field.label && field.value)
: [],
warnings: Array.isArray(item.warnings) ? item.warnings : []
}))
}
function defineFileReceiptId(file, receiptId) {
const normalizedReceiptId = String(receiptId || '').trim()
if (!file || !normalizedReceiptId) {
return false
}
try {
Object.defineProperty(file, 'receiptId', {
value: normalizedReceiptId,
enumerable: false,
configurable: true
})
return true
} catch {
try {
file.receiptId = normalizedReceiptId
return String(file.receiptId || '').trim() === normalizedReceiptId
} catch {
return false
}
}
}
export function attachReceiptFolderIdsToFiles(files = [], payload = null) {
const safeFiles = Array.isArray(files) ? files : []
const documents = Array.isArray(payload?.documents) ? payload.documents : []
let attachedCount = 0
safeFiles.slice(0, documents.length).forEach((file, index) => {
const document = documents[index] || {}
const receiptId = String(document.receipt_id || document.receiptId || '').trim()
if (receiptId && defineFileReceiptId(file, receiptId)) {
attachedCount += 1
}
})
return attachedCount
}
export async function collectReceiptFiles({
files = [],
recognizedAttachmentData = null,
recognizeOcrFiles,
timeoutMs = 90000,
timeoutMessage = '票据 OCR 识别超时,已继续使用附件名称处理。'
} = {}) {
const safeFiles = Array.isArray(files) ? files : []
const reusedData = recognizedAttachmentData && typeof recognizedAttachmentData === 'object'
? recognizedAttachmentData
: null
if (reusedData) {
const ocrDocuments = Array.isArray(reusedData.ocrDocuments) ? [...reusedData.ocrDocuments] : []
const ocrPayload = reusedData.ocrPayload || { documents: ocrDocuments }
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: String(reusedData.ocrSummary || '').trim() || buildOcrSummaryFromDocuments(ocrDocuments),
ocrDocuments,
ocrFilePreviews: Array.isArray(reusedData.ocrFilePreviews) ? [...reusedData.ocrFilePreviews] : []
}
}
if (typeof recognizeOcrFiles !== 'function') {
throw new Error('票据采集服务未配置。')
}
const ocrPayload = await recognizeOcrFiles(safeFiles, {
timeoutMs,
timeoutMessage
})
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: buildOcrSummary(ocrPayload),
ocrDocuments: normalizeOcrDocuments(ocrPayload),
ocrFilePreviews: buildOcrFilePreviews(ocrPayload)
}
}
export function buildOcrSummary(payload) {
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
}
export function buildOcrSummaryFromDocuments(documents) {
return (Array.isArray(documents) ? documents : [])
.slice(0, MAX_OCR_DOCUMENTS)
.map((item) => {
const filename = String(item?.filename || '').trim()
const summary = String(item?.summary || item?.text || '').trim()
if (filename && summary) {
return `${filename}${summary}`
}
return filename || summary
})
.filter(Boolean)
.join('')
}
function resolveAssociationDocumentTypeLabel(document) {
const explicitLabel = String(document?.document_type_label || '').trim()
if (explicitLabel) {
return explicitLabel
}
const sceneLabel = String(document?.scene_label || '').trim()
if (sceneLabel) {
return sceneLabel
}
const typeLabel = resolveDocumentTypeLabel(document?.document_type)
return String(typeLabel || '').trim() || '其他票据'
}
function buildAssociationDocumentContentLines(document) {
const fields = Array.isArray(document?.document_fields) ? document.document_fields : []
const fieldLines = fields
.map((field) => {
const label = String(field?.label || '').trim()
const value = String(field?.value || '').trim()
return label && value ? `- ${label}${value}` : ''
})
.filter(Boolean)
if (fieldLines.length) {
return fieldLines.slice(0, 8)
}
const summary = String(document?.summary || document?.text || '').trim()
if (summary) {
return [`- 识别内容:${summary}`]
}
return ['- 识别内容:暂未提取到结构化字段,请以票据原件为准。']
}
function buildAssociationDocumentCard(lines) {
return (Array.isArray(lines) ? lines : [])
.map((line) => String(line || '').trim() ? `> ${line}` : '>')
.join('\n')
}
export function buildAttachmentAssociationConfirmationMessage({
claimNo = '',
claimTitle = '',
fileNames = [],
ocrDocuments = []
} = {}) {
const documents = Array.isArray(ocrDocuments) && ocrDocuments.length
? ocrDocuments
: (Array.isArray(fileNames) ? fileNames : [])
.map((filename) => ({ filename }))
.filter((item) => String(item.filename || '').trim())
const targetLines = [
claimNo ? `- 草稿单号:${claimNo}` : '',
claimTitle ? `- 单据说明:${claimTitle}` : '',
`- 本次待归集附件:${documents.length || fileNames.length || 0}`
].filter(Boolean)
const documentBlocks = documents.map((document, index) => {
const filename = String(document?.filename || '').trim() || `附件 ${index + 1}`
const typeLabel = resolveAssociationDocumentTypeLabel(document)
const contentLines = buildAssociationDocumentContentLines(document)
.map((line) => String(line || '').replace(/^-\s*/, ''))
return buildAssociationDocumentCard([
`**附件 ${index + 1}${filename}**`,
'',
`附件类型:${typeLabel}`,
'',
...contentLines
])
})
return [
'已识别附件信息:',
'',
documentBlocks.join('\n\n'),
'',
'',
'请问是否确定将票据信息归集到单据:',
'',
targetLines.join('\n'),
'',
'',
`如果 **[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})** 该信息,我将直接将票据进行归集。`
]
.join('\n')
.replace(/\n{4,}/g, '\n\n\n')
.trim()
}
export function normalizeReviewDocumentFieldKey(label) {
const compact = String(label || '').replace(/\s+/g, '').toLowerCase()
if (!compact) return ''
if (
['金额', '价税合计', '合计', '总额', '总计', '票价', '支付金额', '实付金额', '实收金额'].some((token) =>
compact.includes(token.toLowerCase())
)
) {
return 'amount'
}
if (['日期', '时间', '开票日期', '发生时间'].some((token) => compact.includes(token.toLowerCase()))) {
return 'date'
}
if (['商户', '酒店', '销售方', '开票方', '收款方'].some((token) => compact.includes(token.toLowerCase()))) {
return 'merchant_name'
}
if (['票据号码', '发票号码', '票号', '单号', '订单号'].some((token) => compact.includes(token.toLowerCase()))) {
return 'invoice_number'
}
if (compact.includes('发票代码')) {
return 'invoice_code'
}
if (compact.includes('车次') || compact.includes('航班')) {
return 'trip_no'
}
if (compact.includes('行程') || compact.includes('路线')) {
return 'route'
}
return compact
}
export function buildOcrDocumentsFromReviewPayload(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => {
const fields = Array.isArray(item?.fields)
? item.fields
.map((field) => {
const label = String(field?.label || '').trim()
const value = String(field?.value || '').trim()
if (!label || !value) {
return null
}
return {
key: normalizeReviewDocumentFieldKey(label),
label,
value
}
})
.filter(Boolean)
: []
return {
filename: String(item?.filename || '').trim(),
summary: String(item?.summary || '').trim(),
text: [
String(item?.scene_label || '').trim(),
String(item?.summary || '').trim(),
...fields.map((field) => `${field.label}${field.value}`)
]
.filter(Boolean)
.join(' ')
.slice(0, 240),
avg_score: Number(item?.avg_score || 0),
document_type: String(item?.document_type || 'other').trim() || 'other',
document_type_label: resolveDocumentTypeLabel(item?.document_type),
scene_code: resolveExpenseTypeCode(item?.suggested_expense_type),
scene_label: String(item?.scene_label || '').trim(),
preview_kind: String(item?.preview_kind || '').trim(),
preview_data_url: String(item?.preview_data_url || '').trim(),
preview_url: String(item?.preview_url || '').trim(),
document_fields: fields,
warnings: Array.isArray(item?.warnings) ? item.warnings : []
}
}).filter((item) => item.filename)
}
export function mergeUploadAttachmentNames(existingNames, incomingNames) {
const merged = []
const seen = new Set()
for (const value of [...(existingNames || []), ...(incomingNames || [])]) {
const normalized = String(value || '').trim()
if (!normalized || seen.has(normalized)) continue
seen.add(normalized)
merged.push(normalized)
if (merged.length >= MAX_ATTACHMENTS) {
break
}
}
return merged
}
export function mergeUploadOcrDocuments(existingDocuments, incomingDocuments) {
const merged = []
const seen = new Set()
for (const item of [...(existingDocuments || []), ...(incomingDocuments || [])]) {
const filename = String(item?.filename || '').trim()
if (!filename || seen.has(filename)) continue
seen.add(filename)
merged.push(item)
if (merged.length >= MAX_OCR_DOCUMENTS) {
break
}
}
return merged
}
export function inferPreviewKind(file) {
const mediaType = String(file?.type || '').toLowerCase()
const filename = String(file?.name || '').toLowerCase()
if (mediaType.startsWith('image/') || /\.(png|jpg|jpeg|webp|bmp)$/i.test(filename)) {
return 'image'
}
if (mediaType.includes('pdf') || /\.pdf$/i.test(filename)) {
return 'pdf'
}
return 'file'
}
export function buildFilePreviews(files, previewRegistry) {
return files.map((file) => {
const kind = inferPreviewKind(file)
if (!['image', 'pdf'].includes(kind)) {
return {
filename: file.name,
kind
}
}
const url = URL.createObjectURL(file)
previewRegistry.push(url)
return {
filename: file.name,
kind,
url
}
})
}
export function resolveDocumentPreview(filePreviews, filename) {
if (!Array.isArray(filePreviews)) return null
const matches = filePreviews.filter((item) => item.filename === filename)
if (!matches.length) {
return null
}
return (
matches.find((item) => item.kind === 'image' && item.url) ||
matches.find((item) => item.url) ||
matches[0]
)
}
export function isTemporaryPreviewUrl(url) {
return String(url || '').trim().toLowerCase().startsWith('blob:')
}
export function buildFileIdentity(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
}
export function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_ATTACHMENTS) {
const nextFiles = []
const seen = new Set()
for (const file of Array.isArray(existingFiles) ? existingFiles : []) {
const key = buildFileIdentity(file)
if (seen.has(key)) continue
seen.add(key)
nextFiles.push(file)
}
let duplicateCount = 0
let overflowCount = 0
for (const file of Array.isArray(incomingFiles) ? incomingFiles : []) {
const key = buildFileIdentity(file)
if (seen.has(key)) {
duplicateCount += 1
continue
}
if (nextFiles.length >= limit) {
overflowCount += 1
continue
}
seen.add(key)
nextFiles.push(file)
}
return {
files: nextFiles,
duplicateCount,
overflowCount
}
}
export function mergeFilePreviews(existingPreviews, incomingPreviews) {
const result = []
const indexByKey = new Map()
for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) {
const key = [preview?.filename, preview?.kind].join('__')
if (!preview?.filename) continue
const existingIndex = indexByKey.get(key)
if (existingIndex === undefined) {
indexByKey.set(key, result.length)
result.push(preview)
continue
}
const existingPreview = result[existingIndex]
const nextUrl = String(preview?.url || '').trim()
const existingUrl = String(existingPreview?.url || '').trim()
if (nextUrl && (!existingUrl || isTemporaryPreviewUrl(existingUrl) || nextUrl !== existingUrl)) {
result[existingIndex] = preview
}
}
return result
}
export function filterPersistableFilePreviews(filePreviews) {
return (Array.isArray(filePreviews) ? filePreviews : [])
.filter((preview) => {
const filename = String(preview?.filename || '').trim()
const url = String(preview?.url || '').trim()
return filename && !isTemporaryPreviewUrl(url)
})
}
function inferPreviewKindFromUrl(url) {
const normalized = String(url || '').trim().toLowerCase()
if (!normalized) return ''
if (normalized.startsWith('data:image/') || /\.(png|jpg|jpeg|webp|bmp)(?:[?#].*)?$/i.test(normalized)) {
return 'image'
}
if (normalized.startsWith('data:application/pdf') || /\.pdf(?:[?#].*)?$/i.test(normalized)) {
return 'pdf'
}
return ''
}
function resolveDocumentPreviewKind(item) {
const explicit = String(item?.preview_kind || '').trim()
if (explicit) {
return explicit
}
return inferPreviewKindFromUrl(String(item?.preview_url || item?.preview_data_url || '').trim())
}
export function buildOcrFilePreviews(payload) {
const documents = Array.isArray(payload?.documents) ? payload.documents : []
return documents
.map((item) => ({
filename: String(item?.filename || '').trim(),
kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)
}
export function buildReviewFilePreviewsFromReviewPayload(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
return documents
.map((item) => ({
filename: String(item?.filename || '').trim(),
kind: resolveDocumentPreviewKind(item),
url: String(item?.preview_url || item?.preview_data_url || '').trim()
}))
.filter((item) => item.filename && item.kind === 'image' && item.url)
}
export function buildReviewFilePreviewsFromMessages(messages) {
const previews = []
for (const message of Array.isArray(messages) ? messages : []) {
previews.push(...buildReviewFilePreviewsFromReviewPayload(message?.reviewPayload))
}
return mergeFilePreviews([], previews)
}
export function resolveAttachmentPreviewKind(metadata) {
const explicitKind = String(metadata?.preview_kind || '').trim()
if (explicitKind) {
return explicitKind
}
const mediaType = String(metadata?.media_type || '').trim().toLowerCase()
if (mediaType.startsWith('image/')) {
return 'image'
}
if (mediaType === 'application/pdf') {
return 'pdf'
}
return ''
}
export function extractReviewAttachmentNames(reviewPayload) {
const documentNames = Array.isArray(reviewPayload?.document_cards)
? reviewPayload.document_cards.map((item) => String(item?.filename || '').trim()).filter(Boolean)
: []
if (documentNames.length) {
return documentNames
}
const slotMap = buildReviewSlotMap(reviewPayload)
const attachmentValue = String(slotMap.attachments?.value || '').trim()
if (!attachmentValue) {
return []
}
return attachmentValue.split(/[、,]/).map((item) => item.trim()).filter(Boolean)
}
export function buildErrorInsight(error, fileNames = []) {
return {
intent: 'agent',
metricLabel: '运行状态',
metricValue: '失败',
title: '智能体调用失败',
summary: error?.message || '无法连接后端 Orchestrator。',
agent: {
runId: '未生成',
selectedAgent: 'orchestrator',
scenario: '未知',
intent: '未知',
permissionLevel: 'unknown',
routeReason: 'request_failed',
requiresConfirmation: false,
degraded: false,
fileNames,
citations: [],
suggestedActions: [],
queryPayload: null,
draftPayload: null,
reviewPayload: null,
riskFlags: [],
toolCount: 0,
failedToolCount: 0,
selectedCapabilityCodes: [],
filePreviews: [],
statusLabel: '失败',
statusTone: 'note'
}
}
}
export function buildAgentInsight(payload, fileNames = [], filePreviews = []) {
const trace = payload?.trace_summary || {}
const result = payload?.result || {}
const statusLabel = resolveStatusLabel(payload?.status)
return {
intent: 'agent',
metricLabel: '运行状态',
metricValue: statusLabel,
title:
result?.draft_payload?.title ||
`${SCENARIO_LABELS[trace?.scenario] || '通用'}${INTENT_LABELS[trace?.intent] || '处理'}结果`,
summary: result?.answer || result?.message || '智能体已完成处理。',
agent: {
runId: payload?.run_id || '未生成',
selectedAgent: payload?.selected_agent || 'orchestrator',
scenario: SCENARIO_LABELS[trace?.scenario] || trace?.scenario || '未知',
intent: INTENT_LABELS[trace?.intent] || trace?.intent || '未知',
permissionLevel: payload?.permission_level || 'unknown',
routeReason: payload?.route_reason || 'unknown',
requiresConfirmation: Boolean(payload?.requires_confirmation),
degraded: Boolean(trace?.degraded),
fileNames,
citations: Array.isArray(result?.citations) ? result.citations : [],
suggestedActions: Array.isArray(result?.suggested_actions) ? result.suggested_actions : [],
queryPayload: normalizeExpenseQueryPayload(result?.query_payload),
draftPayload: result?.draft_payload || null,
reviewPayload: result?.review_payload || null,
riskFlags: Array.isArray(result?.risk_flags) ? result.risk_flags : [],
toolCount: Number(trace?.tool_count || 0),
failedToolCount: Number(trace?.failed_tool_count || 0),
selectedCapabilityCodes: Array.isArray(trace?.selected_capability_codes)
? trace.selected_capability_codes
: [],
filePreviews,
statusLabel,
statusTone: resolveStatusTone(payload?.status)
}
}
}