refactor(frontend): split large reimbursement and audit modules
This commit is contained in:
428
web/src/views/scripts/travelReimbursementAttachmentModel.js
Normal file
428
web/src/views/scripts/travelReimbursementAttachmentModel.js
Normal file
@@ -0,0 +1,428 @@
|
||||
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 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 || '').slice(0, 240),
|
||||
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(),
|
||||
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 : []
|
||||
}))
|
||||
}
|
||||
|
||||
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(';')
|
||||
}
|
||||
|
||||
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(),
|
||||
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 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 seen = new Set()
|
||||
|
||||
for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) {
|
||||
const key = [preview?.filename, preview?.kind].join('__')
|
||||
if (!preview?.filename || seen.has(key)) continue
|
||||
seen.add(key)
|
||||
result.push(preview)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function buildOcrFilePreviews(payload) {
|
||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||
return documents
|
||||
.map((item) => ({
|
||||
filename: String(item?.filename || '').trim(),
|
||||
kind: String(item?.preview_kind || '').trim(),
|
||||
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: String(item?.preview_kind || '').trim(),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user