Files
X-Financial/web/src/views/scripts/travelReimbursementReviewModel.js
caoxiaozhu 5b388d08c0 feat: 增强知识库索引与设置页面模块化拆分
扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优
化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件
和 Hermes 员工同步子面板并重构样式,新增日志详情组件和
知识入库日志模型,补充单元测试覆盖。
2026-05-22 23:47:28 +08:00

1582 lines
58 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { TRANSPORT_KEYWORD_PATTERN } from '../../utils/reimbursementTextInference.js'
import {
CATEGORY_CONFIDENCE_KEYWORDS,
EXPENSE_CODE_TO_PRESET_SCENE,
EXPENSE_TYPE_LABELS,
REVIEW_CATEGORY_PRESET_OPTIONS,
REVIEW_SCENE_OPTIONS,
REVIEW_SCENE_OTHER_OPTION,
REVIEW_SLOT_CONFIG
} from './travelReimbursementReviewConstants.js'
import {
buildReviewDocumentCorrectionLines,
formatConfidenceLabel,
resolveExpenseTypeLabel
} from './travelReimbursementReviewDocuments.js'
export {
CATEGORY_CONFIDENCE_KEYWORDS,
DATE_INPUT_FORMAT,
DOCUMENT_TYPE_LABELS,
EXPENSE_CODE_TO_PRESET_SCENE,
EXPENSE_TYPE_LABELS,
REVIEW_CATEGORY_PRESET_OPTIONS,
REVIEW_FALLBACK_GROUP_CODES,
REVIEW_OTHER_CATEGORY_OPTIONS,
REVIEW_SCENE_OPTIONS,
REVIEW_SCENE_OTHER_OPTION,
REVIEW_SLOT_CONFIG
} from './travelReimbursementReviewConstants.js'
export {
buildReviewDocumentCorrectionContext,
buildReviewDocumentCorrectionLines,
buildReviewDocumentCorrectionMessage,
buildReviewDocumentDrafts,
buildReviewDocumentSummaries,
cloneReviewDocumentDrafts,
formatConfidenceLabel,
normalizeReviewDocumentComparableValue,
resolveDocumentTypeLabel,
resolveExpenseTypeLabel
} from './travelReimbursementReviewDocuments.js'
export function cloneReviewEditFields(fields) {
const items = Array.isArray(fields) ? fields : []
return items.map((item) => ({
key: String(item?.key || '').trim(),
label: String(item?.label || '').trim(),
value: String(item?.value || ''),
placeholder: String(item?.placeholder || ''),
required: Boolean(item?.required),
field_type: String(item?.field_type || item?.fieldType || 'text').trim() || 'text',
group: String(item?.group || 'basic').trim() || 'basic'
}))
}
export function buildReviewFormValues(fields) {
return cloneReviewEditFields(fields).reduce((result, item) => {
if (!item.key) {
return result
}
result[item.key] = String(item.value || '').trim()
return result
}, {})
}
export function buildBusinessTimeContextFromReviewValues(values = {}) {
const timeText = String(values.time_range || values.business_time || values.occurred_date || '').trim()
if (!timeText) {
return null
}
const matchedDates = timeText.match(/\d{4}-\d{2}-\d{2}/g) || []
if (!matchedDates.length) {
return null
}
const startDate = matchedDates[0]
const endDate = matchedDates[matchedDates.length - 1] || startDate
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return null
}
const displayValue = startDate === endDate ? startDate : `${startDate}${endDate}`
return {
mode: startDate === endDate ? 'single' : 'range',
start_date: startDate,
end_date: endDate,
display_value: displayValue
}
}
export function buildReviewFormContextFromPayload(reviewPayload, inlineState = null) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return {}
}
const fallbackState = buildInlineReviewState(reviewPayload)
const candidateState = inlineState || fallbackState
const hasCandidateValue = Object.values(candidateState || {}).some((value) => {
if (typeof value === 'number') return value > 0
return Boolean(String(value || '').trim())
})
const state = hasCandidateValue ? candidateState : fallbackState
const fields = mergeInlineReviewFields(reviewPayload.edit_fields || [], state)
const values = buildReviewFormValues(fields)
const slotMap = buildReviewSlotMap(reviewPayload)
const inheritedTimeRange = String(
slotMap.time_range?.normalized_value ||
slotMap.time_range?.value ||
values.time_range ||
values.business_time ||
values.occurred_date ||
''
).trim()
if (inheritedTimeRange) {
values.time_range = values.time_range || inheritedTimeRange
values.business_time = values.business_time || inheritedTimeRange
}
const businessTimeContext = buildBusinessTimeContextFromReviewValues(values)
return {
review_form_values: values,
...(businessTimeContext ? { business_time_context: businessTimeContext } : {})
}
}
export function buildReviewEditFieldMap(fields) {
return cloneReviewEditFields(fields).reduce((result, item) => {
if (!item.key) return result
result[item.key] = item
return result
}, {})
}
export function createEmptyInlineReviewState() {
return {
occurred_date: '',
amount: '',
transport_type: '',
scene_label: '',
reason_value: '',
customer_name: '',
location: '',
merchant_name: '',
participants: '',
attachment_names: '',
attachment_count: 0,
pending_attachment_count: 0,
expense_type: ''
}
}
export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const expenseType = resolveExpenseTypeCode(
inlineState?.expense_type ||
buildReviewSlotMap(reviewPayload).expense_type?.normalized_value ||
buildReviewSlotMap(reviewPayload).expense_type?.value ||
''
)
if (['travel', 'hotel'].includes(expenseType)) {
return true
}
return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
return (
['flight_itinerary', 'train_ticket', 'hotel_invoice'].includes(documentType) ||
['travel', 'hotel'].includes(suggestedType)
)
})
}
export function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const labels = []
const appendLabel = (label) => {
if (label && !labels.includes(label)) {
labels.push(label)
}
}
for (const item of documents) {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const text = [
item?.filename,
item?.summary,
item?.scene_label,
item?.suggested_expense_type,
...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : [])
].join(' ')
const compact = text.replace(/\s+/g, '')
if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) {
appendLabel('飞机')
} else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) {
appendLabel('火车/高铁')
} else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) {
appendLabel('打车/网约车')
}
}
const fallback = String(fallbackText || '').replace(/\s+/g, '')
if (!labels.length) {
if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机')
if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁')
if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车')
}
return labels.join('、')
}
export function resolveReviewRecognizedSlotCards(reviewPayload) {
return Array.isArray(reviewPayload?.slot_cards)
? reviewPayload.slot_cards.filter((item) => item.status !== 'missing')
: []
}
export function resolveReviewMissingSlotCards(reviewPayload) {
return Array.isArray(reviewPayload?.slot_cards)
? reviewPayload.slot_cards.filter((item) => item.status === 'missing')
: []
}
export function resolveReviewExtraMissingLabels(reviewPayload) {
const labels = Array.isArray(reviewPayload?.missing_slots)
? reviewPayload.missing_slots
.map((item) => {
if (item && typeof item === 'object') {
return String(item.label || item.title || item.key || '').trim()
}
return String(item || '').trim()
})
.filter(Boolean)
: []
if (!labels.length) return []
const slotLabels = new Set(
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).flatMap((item) => [
String(item?.label || '').trim(),
String(item?.key || '').trim()
]).filter(Boolean)
)
return labels.filter((label) => !slotLabels.has(label))
}
export function buildReviewRecognizedLines(reviewPayload) {
return resolveReviewRecognizedSlotCards(reviewPayload)
.filter((item) => String(item?.value || '').trim())
.map((item) => `${item.label}${item.value}`)
}
export function buildReviewSlotMap(reviewPayload) {
return Object.fromEntries(
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).map((item) => [item.key, item])
)
}
export function resolveExpenseTypeCode(value) {
const normalized = String(value || '').trim()
if (!normalized) return 'other'
if (EXPENSE_TYPE_LABELS[normalized]) return normalized
const matched = Object.entries(EXPENSE_TYPE_LABELS).find(([, label]) => label === normalized)
return matched?.[0] || 'other'
}
export function isValidIsoDateString(value) {
const normalized = String(value || '').trim()
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
return false
}
const [yearText, monthText, dayText] = normalized.split('-')
const year = Number(yearText)
const month = Number(monthText)
const day = Number(dayText)
if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) {
return false
}
const candidate = new Date(Date.UTC(year, month - 1, day))
return (
candidate.getUTCFullYear() === year &&
candidate.getUTCMonth() === month - 1 &&
candidate.getUTCDate() === day
)
}
export function parseAmountNumber(value) {
const normalized = String(value || '')
.replace(/[,\s]/g, '')
.replace(/[¥¥]/g, '')
.replace(/元/g, '')
.trim()
if (!normalized || !/^\d+(?:\.\d+)?$/.test(normalized)) {
return null
}
const amount = Number(normalized)
return Number.isFinite(amount) ? amount : null
}
export function normalizeAmountValue(value) {
const amount = parseAmountNumber(value)
if (amount === null) {
return ''
}
return Number.isInteger(amount) ? `${amount}` : `${amount.toFixed(2).replace(/\.?0+$/, '')}`
}
export function extractAmountInputValue(value) {
const amount = parseAmountNumber(value)
if (amount === null) {
return String(value || '').trim()
}
return Number.isInteger(amount) ? String(amount) : amount.toFixed(2).replace(/\.?0+$/, '')
}
export function formatAmountDisplay(value) {
const amount = parseAmountNumber(value)
if (amount === null) {
return String(value || '').trim()
}
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
maximumFractionDigits: Number.isInteger(amount) ? 0 : 2
}).format(amount)
}
export function matchPresetSceneFromReason(reason) {
const compactReason = String(reason || '').trim().replace(/\s+/g, '')
if (!compactReason) {
return ''
}
if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) {
return '请客户吃饭'
}
if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) {
const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, '')))
if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) {
return matchedPreset
}
}
if (/出差|差旅/.test(compactReason)) {
return '出差行程'
}
if (/酒店|住宿/.test(compactReason)) {
return '住宿报销'
}
if (TRANSPORT_KEYWORD_PATTERN.test(compactReason)) {
return '交通出行'
}
if (/会务|会议|参会|论坛|展会/.test(compactReason)) {
return '会务活动'
}
return ''
}
export function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') {
const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)]
if (fromCode) {
return fromCode
}
const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '')
if (!compactLabel) {
return ''
}
if (/差旅|出差/.test(compactLabel)) {
return '出差行程'
}
if (/住宿|酒店/.test(compactLabel)) {
return '住宿报销'
}
if (/交通/.test(compactLabel)) {
return '交通出行'
}
if (/招待|餐饮|餐费|伙食/.test(compactLabel)) {
return '请客户吃饭'
}
if (/会务|会议/.test(compactLabel)) {
return '会务活动'
}
return ''
}
export function mapExpenseTypeLabelToPresetScene(expenseType) {
const code = resolveExpenseTypeCode(expenseType)
if (EXPENSE_CODE_TO_PRESET_SCENE[code]) {
return EXPENSE_CODE_TO_PRESET_SCENE[code]
}
const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '')
if (!compactLabel) {
return ''
}
if (compactLabel.includes('差旅') || compactLabel.includes('出差')) {
return '出差行程'
}
if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) {
return '住宿报销'
}
if (compactLabel.includes('交通')) {
return '交通出行'
}
if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) {
return '请客户吃饭'
}
if (compactLabel.includes('会务') || compactLabel.includes('会议')) {
return '会务活动'
}
return matchPresetSceneFromReason(expenseType)
}
export function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
if (documents.length) {
const votes = new Map()
for (const document of documents) {
const preset =
mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type)
|| mapExpenseTypeLabelToPresetScene(document.suggested_expense_type)
if (!preset) {
continue
}
votes.set(preset, (votes.get(preset) || 0) + 1)
}
if (votes.size) {
return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0]
}
}
const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : []
if (claimGroups.length === 1) {
const group = claimGroups[0]
const preset =
mapExpenseTypeLabelToPresetScene(group.expense_type)
|| mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type)
if (preset) {
return preset
}
}
const fromReason = matchPresetSceneFromReason(reasonValue)
if (fromReason) {
return fromReason
}
const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType)
if (fromExpenseType) {
return fromExpenseType
}
if (String(reasonValue || '').trim()) {
return REVIEW_SCENE_OTHER_OPTION
}
return '待补充'
}
export function formatReviewSceneDisplayValue(inlineState) {
const scene = String(inlineState?.scene_label || '').trim()
if (!scene || scene === '待补充') {
return '待补充'
}
if (scene === REVIEW_SCENE_OTHER_OPTION) {
const detail = String(inlineState?.reason_value || '').trim()
if (!detail) {
return REVIEW_SCENE_OTHER_OPTION
}
return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}${detail}`
}
return scene
}
export function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) {
return inferPresetSceneFromReview(reviewPayload, reason, expenseType)
}
export function buildInlineReviewState(reviewPayload) {
const slotMap = buildReviewSlotMap(reviewPayload)
const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields)
const attachmentNames = String(
editFieldMap.attachment_names?.value ||
slotMap.attachments?.value ||
(Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards.map((item) => item.filename).join('、') : '')
).trim()
const attachmentCount = Array.isArray(reviewPayload?.document_cards)
? reviewPayload.document_cards.length
: attachmentNames
? attachmentNames.split('、').filter(Boolean).length
: 0
const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim()
const reasonValue = String(
editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
).trim()
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
const transportType = String(
editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue)
).trim()
return {
occurred_date: String(
editFieldMap.occurred_date?.value || slotMap.time_range?.normalized_value || slotMap.time_range?.value || ''
).trim(),
amount: normalizeAmountValue(
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
),
transport_type: transportType,
scene_label: sceneLabel,
reason_value:
sceneLabel === REVIEW_SCENE_OTHER_OPTION
? reasonValue
: String(slotMap.reason?.raw_value || '').trim() || reasonValue,
customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(),
location: String(
editFieldMap.business_location?.value ||
editFieldMap.location?.value ||
slotMap.location?.normalized_value ||
slotMap.location?.value ||
''
).trim(),
merchant_name: String(editFieldMap.merchant_name?.value || slotMap.merchant_name?.value || '').trim(),
participants: String(editFieldMap.participants?.value || slotMap.participants?.value || '').trim(),
attachment_names: attachmentNames,
attachment_count: attachmentCount,
pending_attachment_count: 0,
expense_type: expenseType
}
}
export function buildReviewAttachmentStatus(reviewPayload) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
if (!documents.length) return '未上传'
return documents.length === 1 ? '已上传 1 份' : `已上传 ${documents.length}`
}
export function shouldShowReviewFactCard(reviewPayload, slotKey, value = '') {
const slotMap = buildReviewSlotMap(reviewPayload)
const slot = slotMap[slotKey]
return Boolean(String(value || slot?.normalized_value || slot?.value || '').trim()) || slot?.status === 'missing'
}
export function buildReviewEvidenceText(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const slotMap = buildReviewSlotMap(reviewPayload)
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
return [
String(inlineState.reason_value || '').trim(),
String(inlineState.scene_label || '').trim(),
String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim(),
...documents.map((item) =>
[item.scene_label, item.summary, item.filename, ...(Array.isArray(item.warnings) ? item.warnings : [])]
.filter(Boolean)
.join(' ')
)
]
.filter(Boolean)
.join(' ')
.toLowerCase()
}
export function resolveReviewCategoryTextScore(text, categoryCode) {
const patterns = CATEGORY_CONFIDENCE_KEYWORDS[categoryCode]
if (!patterns?.length || !text) {
return 0
}
return patterns.some((pattern) => pattern.test(text))
? {
travel: 0.84,
hotel: 0.82,
transport: 0.8,
meal: 0.76,
meeting: 0.78,
entertainment: 0.88,
office: 0.74,
training: 0.77,
communication: 0.7,
welfare: 0.72
}[categoryCode] || 0
: 0
}
export function resolveReviewCategoryDocumentScore(reviewPayload, categoryCode) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const matchedScores = documents
.filter((item) => resolveExpenseTypeCode(item?.suggested_expense_type) === categoryCode)
.map((item) => Number(item?.avg_score || 0))
.filter((score) => Number.isFinite(score) && score > 0)
if (!matchedScores.length) {
return 0
}
return matchedScores.reduce((sum, score) => sum + score, 0) / matchedScores.length
}
export function resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) {
const normalizedLabel = String(selectedLabel || '').trim()
if (!normalizedLabel) {
return 0
}
const selectedCode = resolveExpenseTypeCode(normalizedLabel)
const slotMap = buildReviewSlotMap(reviewPayload)
const expenseSlot = slotMap.expense_type
const recognizedCode = resolveExpenseTypeCode(expenseSlot?.normalized_value || expenseSlot?.value || '')
let score = 0
if (recognizedCode === selectedCode) {
score = Math.max(score, Number(expenseSlot?.confidence || 0))
}
score = Math.max(score, resolveReviewCategoryDocumentScore(reviewPayload, selectedCode))
score = Math.max(score, resolveReviewCategoryTextScore(buildReviewEvidenceText(reviewPayload, inlineState), selectedCode))
if (!score && normalizedLabel) {
score = selectedCode === 'other' ? 0.52 : 0.58
}
return Math.max(0, Math.min(0.98, Number(score.toFixed(2))))
}
export function buildReviewCategoryOptions(reviewPayload, selectedLabel = '', inlineState = createEmptyInlineReviewState()) {
const presetLabels = REVIEW_CATEGORY_PRESET_OPTIONS.filter((item) => !item.is_other).map((item) => item.label)
return REVIEW_CATEGORY_PRESET_OPTIONS.map((item, index) => ({
...item,
active: item.is_other ? Boolean(selectedLabel) && !presetLabels.includes(selectedLabel) : item.label === selectedLabel,
confidenceLabel: item.is_other
? formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))
: formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState)),
caption: item.is_other
? selectedLabel && !presetLabels.includes(selectedLabel)
? `${selectedLabel} · ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, selectedLabel, inlineState))}`
: '点击选择更多类型'
: `置信度 ${formatConfidenceLabel(resolveReviewCategoryConfidenceScore(reviewPayload, item.label, inlineState))}`,
groupLabel: index === 0 ? '常用' : index < 5 ? '常用' : '更多'
}))
}
export function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInlineReviewState()) {
return formatConfidenceLabel(
resolveReviewCategoryConfidenceScore(reviewPayload, inlineState.expense_type, inlineState)
)
}
export function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
const state = inlineState || createEmptyInlineReviewState()
if (slotKey === 'expense_type') return String(state.expense_type || '').trim()
if (slotKey === 'customer_name') return String(state.customer_name || '').trim()
if (slotKey === 'time_range') return String(state.occurred_date || '').trim()
if (slotKey === 'location') return String(state.location || '').trim()
if (slotKey === 'merchant_name') return String(state.merchant_name || '').trim()
if (slotKey === 'amount') return String(state.amount || '').trim()
if (slotKey === 'reason') return String(state.reason_value || state.scene_label || '').trim()
if (slotKey === 'participants') return String(state.participants || '').trim()
if (slotKey === 'attachments') {
return String(state.attachment_names || '').trim()
|| (Number(state.attachment_count || 0) > 0 ? `${Number(state.attachment_count)} 份附件` : '')
|| (Number(state.pending_attachment_count || 0) > 0 ? `${Number(state.pending_attachment_count)} 份待上传附件` : '')
}
return ''
}
export function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
const actions = Array.isArray(reviewPayload?.confirmation_actions)
? reviewPayload.confirmation_actions.map((item) => ({ ...item }))
: []
const actionTypes = new Set(actions.map((item) => String(item?.action_type || '').trim()))
const associationPending = actionTypes.has('link_to_existing_draft') || actionTypes.has('create_new_claim_from_documents')
if (!canProceed || associationPending) {
return actions
}
const syncedActions = actions.filter((item) => String(item?.action_type || '').trim() !== 'next_step')
if (!syncedActions.some((item) => String(item?.action_type || '').trim() === 'save_draft')) {
syncedActions.push({
label: '保存为草稿',
action_type: 'save_draft',
description: '先暂存当前已识别信息,稍后仍可继续补充或提交。',
emphasis: 'secondary'
})
}
return [
...syncedActions,
{
label: '继续下一步',
action_type: 'next_step',
description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。',
emphasis: 'primary'
}
]
}
export function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
if (!reviewPayload || typeof reviewPayload !== 'object') {
return reviewPayload
}
const nextSlotCards = (Array.isArray(reviewPayload.slot_cards) ? reviewPayload.slot_cards : []).map((slot) => {
const value = resolveInlineReviewSlotValue(slot.key, inlineState)
const required = Boolean(slot.required)
const filled = Boolean(value)
return {
...slot,
value: value || slot.value || '',
normalized_value: value || slot.normalized_value || '',
raw_value: value || slot.raw_value || '',
source: filled ? 'user_form' : slot.source,
source_label: filled ? '用户修改' : slot.source_label,
confidence: filled ? Math.max(Number(slot.confidence || 0), 0.98) : Number(slot.confidence || 0),
confirmed: filled || Boolean(slot.confirmed),
status: required && !filled ? 'missing' : filled ? 'identified' : slot.status,
hint: required && !filled ? slot.hint : ''
}
})
const missingSlots = nextSlotCards
.filter((slot) => slot.required && slot.status === 'missing')
.map((slot) => slot.label || slot.key)
const extraMissingSlots = resolveReviewExtraMissingLabels({
...reviewPayload,
slot_cards: nextSlotCards
})
const allMissingSlots = [...missingSlots, ...extraMissingSlots]
const canProceed = allMissingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
return {
...reviewPayload,
can_proceed: canProceed,
missing_slots: allMissingSlots,
slot_cards: nextSlotCards,
edit_fields: mergeInlineReviewFields(reviewPayload.edit_fields || [], inlineState),
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
}
}
export function normalizeInlineReviewComparableState(state) {
const source = state && typeof state === 'object' ? state : {}
return {
occurred_date: String(source.occurred_date || '').trim(),
amount: String(source.amount || '').trim(),
transport_type: String(source.transport_type || '').trim(),
scene_label: String(source.scene_label || '').trim(),
reason_value: String(source.reason_value || '').trim(),
customer_name: String(source.customer_name || '').trim(),
location: String(source.location || '').trim(),
merchant_name: String(source.merchant_name || '').trim(),
participants: String(source.participants || '').trim(),
attachment_names: String(source.attachment_names || '').trim(),
pending_attachment_count: Math.max(0, Number(source.pending_attachment_count || 0)),
expense_type: String(source.expense_type || '').trim()
}
}
export function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = []) {
const base = normalizeInlineReviewComparableState(baseState)
const next = normalizeInlineReviewComparableState(nextState)
const lines = []
if (base.occurred_date !== next.occurred_date) {
lines.push(`发生时间 ${next.occurred_date || '待补充'}`)
}
if (base.amount !== next.amount) {
lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`)
}
if (base.transport_type !== next.transport_type) {
lines.push(`交通类型 ${next.transport_type || '待确认'}`)
}
if (base.scene_label !== next.scene_label) {
lines.push(`场景 ${next.scene_label || '待补充'}`)
}
if (base.customer_name !== next.customer_name) {
lines.push(`关联客户 ${next.customer_name || '待补充'}`)
}
if (base.location !== next.location) {
lines.push(`业务地点 ${next.location || '待补充'}`)
}
if (base.merchant_name !== next.merchant_name) {
lines.push(`酒店/商户 ${next.merchant_name || '待补充'}`)
}
if (base.participants !== next.participants) {
lines.push(`同行人员 ${next.participants || '待补充'}`)
}
if (base.expense_type !== next.expense_type) {
lines.push(`报销分类 ${next.expense_type || '待补充'}`)
}
if (base.attachment_names !== next.attachment_names || pendingFiles.length) {
lines.push(`票据 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`)
}
return lines
}
export function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = []) {
const base = normalizeInlineReviewComparableState(baseState)
const next = normalizeInlineReviewComparableState(nextState)
const fieldConfigs = [
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
{ key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' },
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
{ key: 'merchant_name', label: '酒店/商户', format: (value) => value || '待补充' },
{ key: 'participants', label: '同行人员', format: (value) => value || '待补充' },
{ key: 'expense_type', label: '报销分类', format: (value) => value || '待补充' }
]
const phrases = fieldConfigs.reduce((result, item) => {
if (base[item.key] !== next[item.key]) {
result.push(`${item.label}修改为 ${item.format(next[item.key])}`)
}
return result
}, [])
if (base.attachment_names !== next.attachment_names || pendingFiles.length) {
phrases.push(`票据修改为 ${next.attachment_names || (pendingFiles.length ? `已选择 ${pendingFiles.length} 份附件` : '待上传')}`)
}
return phrases
}
export function buildLocalReviewSavedMessage(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) {
const phrases = buildInlineReviewChangePhrases(baseState, nextState, pendingFiles)
const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
if (documentLines.length) {
phrases.push(`${documentLines.length} 张票据识别信息更新为最新修改`)
}
if (!phrases.length) {
return '右侧核对信息已保存。'
}
return `已将${phrases.join('')}`
}
export function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) {
const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
if (!lines.length) {
return '我已校正核对信息,请按最新内容更新。'
}
return `我已校正核对信息:${lines.join('')}。请按最新内容更新。`
}
export function buildReviewSubmitUserText(baseState, nextState, pendingFiles = [], baseDrafts = [], nextDrafts = []) {
const inlineLines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
const documentLines = buildReviewDocumentCorrectionLines(baseDrafts, nextDrafts)
if (!inlineLines.length && !documentLines.length) {
return '我已校正核对信息,请按最新内容更新。'
}
const parts = []
if (inlineLines.length) {
parts.push(inlineLines.join(''))
}
if (documentLines.length) {
parts.push(`修正了 ${documentLines.length} 张票据识别信息`)
}
return `我已校正核对信息:${parts.join('')}。请按最新内容更新。`
}
export function mergeInlineReviewFields(baseFields, inlineState) {
const merged = cloneReviewEditFields(baseFields)
const updateMap = {
expense_type: inlineState.expense_type,
transport_type: inlineState.transport_type,
occurred_date: inlineState.occurred_date,
amount: inlineState.amount,
customer_name: inlineState.customer_name,
business_location: inlineState.location,
merchant_name: inlineState.merchant_name,
participants: inlineState.participants,
reason: inlineState.reason_value || inlineState.scene_label,
attachment_names: inlineState.attachment_names
}
for (const item of merged) {
if (!(item.key in updateMap)) continue
item.value = String(updateMap[item.key] || '').trim()
}
return merged
}
function resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver = null) {
if (typeof riskBriefResolver === 'function') {
return riskBriefResolver(reviewPayload)
}
return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []
}
export function buildClientTimeContext() {
const now = new Date()
const locale =
typeof navigator !== 'undefined' && typeof navigator.language === 'string'
? navigator.language
: 'zh-CN'
return {
client_now_iso: now.toISOString(),
client_timezone_offset_minutes: now.getTimezoneOffset(),
client_locale: locale
}
}
export function formatDraftApplyTime(date = new Date()) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
export function formatDateInputValue(date = new Date()) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function buildDraftSavedPayload({
draftPayload,
reviewPayload,
inlineState,
linkedRequest,
currentUser,
riskItems = []
}) {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
const typeCode = resolveExpenseTypeCode(inlineState?.expense_type)
const amountNumber = parseAmountNumber(inlineState?.amount)
const location = String(inlineState?.location || linkedRequest?.city || '').trim()
const customerName = String(inlineState?.customer_name || '').trim()
const typeLabel = String(inlineState?.expense_type || linkedRequest?.typeLabel || resolveExpenseTypeLabel(typeCode)).trim()
const title =
String(inlineState?.reason_value || '').trim()
|| String(inlineState?.scene_label || '').trim()
|| String(draftPayload?.title || '').trim()
|| `${typeLabel}报销草稿`
const sceneLabel =
String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel
const attachmentSummary = documents.length
? `${documents.length} 条识别票据 / ${documents.length} 份材料`
: String(inlineState?.attachment_names || '').trim()
? '1 条识别票据 / 1 份材料'
: '待上传票据'
return {
claimId: String(draftPayload?.claim_id || '').trim(),
claimNo: String(draftPayload?.claim_no || '').trim(),
status: String(draftPayload?.status || '').trim(),
approvalStage: String(draftPayload?.approval_stage || '').trim(),
person: String(currentUser?.name || '').trim() || '当前用户',
dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门',
entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.',
typeCode,
typeLabel,
detailVariant: typeCode === 'travel' ? 'travel' : 'general',
title,
sceneLabel,
sceneTarget: location || customerName || '待补充',
location,
relatedCustomer: customerName,
occurredDisplay: String(inlineState?.occurred_date || '').trim() || '待补充',
applyTime: formatDraftApplyTime(),
amount: amountNumber === null ? 0 : amountNumber,
secondaryStatusLabel: typeCode === 'travel' ? '行程状态' : '票据状态',
secondaryStatusValue: documents.length ? '待继续完善' : '待上传票据',
secondaryStatusTone: documents.length ? 'warning' : 'neutral',
riskSummary: riskItems[0]?.summary || (missingItems.length ? '仍有识别字段待补充,请继续完善。' : 'AI 已生成草稿,可继续补充后提交。'),
attachmentSummary,
expenseTableSummary: documents.length
? `已关联 ${documents.length} 份票据,请继续在报销页补充和确认`
: '当前尚未上传票据,请在报销页继续补充附件',
note: String(draftPayload?.status || '').trim() === 'submitted'
? '该报销单已由 AI 工作台提交审批,可在个人报销页面持续跟踪进度。'
: '该草稿由 AI 工作台根据当前识别结果生成,可在个人报销页面继续补充明细、票据与说明。'
}
}
export function countReviewPendingItems(reviewPayload) {
return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length
}
export function countReviewRiskItems(reviewPayload, riskBriefResolver = null) {
return resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver).length
}
export function buildReviewHeadline(reviewPayload) {
if (countReviewPendingItems(reviewPayload)) {
return '待补充信息'
}
if (reviewPayload?.can_proceed) {
return '识别结果已整理完成'
}
return '识别结果摘要'
}
export function buildReviewSubline(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
if (pendingCount) {
return `我已把 ${pendingCount} 项待补充内容整理成文字说明,请先核查。`
}
if (reviewPayload?.can_proceed) {
return '当前关键信息已基本齐全,确认无误后可以继续下一步。'
}
return '已为您整理本轮识别结果,请核查当前识别摘要。'
}
export function buildReviewStateLabel(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
if (pendingCount) return `待补充 ${pendingCount}`
if (reviewPayload?.can_proceed) return '可继续处理'
return '已识别'
}
export function buildReviewStateTone(reviewPayload) {
return reviewPayload?.can_proceed && !countReviewPendingItems(reviewPayload)
? 'ready'
: 'pending'
}
export function buildReviewDisclosureTitle(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
if (pendingCount) {
return `当前有 ${pendingCount} 项待补充,点击展开查看`
}
return '当前信息已齐全,可展开查看识别摘要'
}
export function buildReviewDisclosureHint(reviewPayload) {
const pendingCount = countReviewPendingItems(reviewPayload)
if (pendingCount) {
return '展开后可查看待补充字段和处理建议'
}
return '展开后可查看本轮已识别的关键信息'
}
export function shouldOpenReviewDisclosure(reviewPayload) {
return !countReviewPendingItems(reviewPayload)
}
export function buildReviewTodoSectionTitle(reviewPayload) {
return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息'
}
export function buildReviewTodoSectionMeta(reviewPayload) {
const count = buildReviewTodoItems(reviewPayload).length
if (countReviewPendingItems(reviewPayload)) {
return count ? `${count}` : '待确认'
}
return count ? `${count}` : '已齐全'
}
export function buildReviewAlertLabel(slotKey, expenseTypeLabel = '') {
if (slotKey === 'customer_name') {
return expenseTypeLabel === '业务招待费' ? '业务招待费需补充关联客户' : '缺少关联客户'
}
if (slotKey === 'participants') return '缺少同行人员'
if (slotKey === 'attachments') return '票据状态待补充'
if (slotKey === 'amount') return '金额待确认'
if (slotKey === 'time_range') return '发生时间待确认'
if (slotKey === 'reason') return '场景 / 事由待补充'
if (slotKey === 'expense_type') return '报销类型待确认'
if (slotKey === 'location') return '业务地点待补充'
if (slotKey === 'merchant_name') return '酒店/商户待补充'
return '仍有信息待补充'
}
export function buildReviewAlertChips(reviewPayload, riskBriefResolver = null) {
const slotMap = buildReviewSlotMap(reviewPayload)
const expenseTypeLabel = String(slotMap.expense_type?.value || '').trim()
const chips = []
for (const item of resolveReviewMissingSlotCards(reviewPayload).slice(0, 3)) {
chips.push({
key: item.key,
label: buildReviewAlertLabel(item.key, expenseTypeLabel),
tone: 'warning'
})
}
if (chips.length < 3) {
for (const label of resolveReviewExtraMissingLabels(reviewPayload)) {
chips.push({
key: label,
label,
tone: 'warning'
})
if (chips.length >= 3) break
}
}
if (chips.length < 3) {
for (const risk of resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver)) {
if (chips.some((item) => item.label === risk.title)) continue
chips.push({
key: risk.title,
label: risk.title,
tone: risk.level === 'high' ? 'danger' : 'warning'
})
if (chips.length >= 3) break
}
}
if (!chips.length && reviewPayload?.can_proceed) {
chips.push({
key: 'ready',
label: '当前识别信息已可继续处理',
tone: 'success'
})
}
return chips
}
export function buildReviewTodoItems(reviewPayload) {
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload)
if (missingItems.length || extraMissingLabels.length) {
return [
...missingItems.map((item) => {
const config = REVIEW_SLOT_CONFIG[item.key] || {}
return {
key: item.key,
icon: config.icon || 'mdi mdi-form-select',
title: config.title || item.label,
hint: item.hint || config.hint || `请补充${item.label}`,
status: config.status || '待补充',
tone: 'warning'
}
}),
...extraMissingLabels.map((label, index) => ({
key: `extra-missing-${index}-${label}`,
icon: label.includes('酒店') || label.includes('住宿') ? 'mdi mdi-bed-outline' : 'mdi mdi-file-alert-outline',
title: label,
hint: label.includes('必须')
? '该票据属于当前差旅提交的必备材料,补齐后才能继续下一步。'
: '可以继续补充该材料;如暂时没有,也可以按当前信息处理。',
status: label.includes('必须') ? '必须补齐' : '可选补充',
tone: 'warning'
}))
]
}
return resolveReviewRecognizedSlotCards(reviewPayload)
.filter((item) => String(item?.value || '').trim())
.slice(0, 3)
.map((item) => {
const config = REVIEW_SLOT_CONFIG[item.key] || {}
return {
key: item.key,
icon: config.icon || 'mdi mdi-check-circle-outline',
title: config.title || item.label,
hint: `已识别:${item.value}`,
status: '已识别',
tone: 'ready'
}
})
}
const REVIEW_PENDING_HINT_COPY = {
expense_type: '请选择本次报销分类,后续票据会按这个分类继续核对。',
customer_name: '请补充客户单位全称。',
time_range: '请补充业务发生日期或时间范围。',
location: '请补充业务发生地点。',
merchant_name: '请补充酒店或商户名称。',
amount: '请补充本次费用金额。',
reason: '请补充本次费用场景或事由。',
participants: '请至少填写 1 名同行人员。',
attachments: '请上传或关联对应票据附件。'
}
function normalizeReviewFollowupSentence(text) {
const normalized = String(text || '')
.replace(/^已识别[:]\s*/, '')
.replace(/^建议补充\s*/, '请补充')
.replace(/\s+/g, ' ')
.trim()
if (!normalized) return ''
return /[。!?.!?]$/.test(normalized) ? normalized : `${normalized}`
}
function buildReviewPlainFollowupItem(item, pendingMode) {
const key = String(item?.key || '').trim()
const label = String(item?.title || item?.label || '').trim() || '待核查信息'
if (pendingMode) {
return {
key: key || label,
label,
text: normalizeReviewFollowupSentence(REVIEW_PENDING_HINT_COPY[key] || item?.hint || `请补充${label}`)
}
}
const value = normalizeReviewFollowupSentence(item?.hint || '')
return {
key: key || label,
label,
text: value || '已识别,请核查是否准确。'
}
}
const REVIEW_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。请核查对话中的文字说明;如果想先暂存,也可以点击对话文字中的“草稿”。`,
({ issueSummary }) => `我这边看到还有 ${issueSummary},建议先把下方内容核对一下;暂时不处理也没关系,可以点击“草稿”先保存。`,
({ issueSummary }) => `下方还有 ${issueSummary},需要你确认。信息没补齐前可以先核查说明,后续需要暂存时点“草稿”。`,
({ issueSummary }) => `这笔报销还有 ${issueSummary},尚未完全确认。请先看一下下面的补充项;需要中途保存时,可以点“草稿”。`,
({ issueSummary }) => `目前还有 ${issueSummary}。你可以先按下面的提示补充,也可以稍后再处理,点击“草稿”即可暂存当前信息。`,
({ issueSummary }) => `还有 ${issueSummary},建议先核对下面说明;如果票据或金额暂时不全,可以通过“草稿”保留当前进度。`,
({ issueSummary }) => `这次识别结果里还有 ${issueSummary}。请重点看下面几项,暂不提交时可以点“草稿”保存。`,
({ issueSummary }) => `我还需要你确认 ${issueSummary}。下面列出了具体内容;如果现在不方便补齐,可以先点“草稿”。`,
({ issueSummary }) => `当前还有 ${issueSummary},需要进一步处理。请根据下面提示核查,待补充完再继续;临时保存可点击“草稿”。`,
({ issueSummary }) => `本次报销还有 ${issueSummary},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
]
const REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES = [
({ issueSummary }) => `当前还有 ${issueSummary}。草稿已保存,后续上传票据时请关联这张草稿,补齐后再继续提交审批。`,
({ issueSummary }) => `这张草稿仍有 ${issueSummary} 需要补充。您可以继续上传或关联票据,系统会归集到已保存草稿中。`,
({ issueSummary }) => `草稿已生成,当前还差 ${issueSummary}。请按下方提示补充字段或票据,完整后再进入下一步。`,
({ issueSummary }) => `草稿已经留存,下面还有 ${issueSummary} 待处理。新增附件请关联当前草稿,避免重复建单。`,
({ issueSummary }) => `当前草稿还有 ${issueSummary}。建议先补齐金额、票据等信息,再从草稿详情继续提交审批。`,
({ issueSummary }) => `已保留当前进度,这笔草稿还需要 ${issueSummary}。后续补充内容会作为该草稿的更新处理。`,
({ issueSummary }) => `这张单据已进入草稿状态,仍有 ${issueSummary}。请继续补充必要信息,补齐后再发起正式提交。`,
({ issueSummary }) => `草稿保存完成后,当前还剩 ${issueSummary}。上传附件时请选择关联这张草稿,系统会继续合并识别结果。`,
({ issueSummary }) => `当前草稿待完善:${issueSummary}。请先处理下方项目,确认完整后再继续下一步。`,
({ issueSummary }) => `这笔草稿还存在 ${issueSummary}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。`
]
function buildStableTemplateIndex(signature, total) {
const source = String(signature || '')
let hash = 0
for (let index = 0; index < source.length; index += 1) {
hash = ((hash << 5) - hash + source.charCodeAt(index)) >>> 0
}
return total ? hash % total : 0
}
function buildReviewPendingSummary(pendingCount, riskCount, signature = '', options = {}) {
const issueParts = []
if (pendingCount) {
issueParts.push(`${pendingCount} 项信息待补充`)
}
if (riskCount) {
issueParts.push(`${riskCount} 条风险提醒`)
}
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
const templates = options.savedDraft
? REVIEW_SAVED_DRAFT_PENDING_SUMMARY_TEMPLATES
: REVIEW_PENDING_SUMMARY_TEMPLATES
const templateIndex = buildStableTemplateIndex(signature || issueSummary, templates.length)
return templates[templateIndex]({ issueSummary })
}
export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
const savedDraft = Boolean(options?.savedDraft)
const todoItems = buildReviewTodoItems(reviewPayload)
const pendingCount = countReviewPendingItems(reviewPayload)
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
if (pendingCount || extraMissingCount) {
const summarySignature = [
pendingCount || extraMissingCount,
riskBriefs.length,
...todoItems.map((item) => `${item.key}:${item.title}:${item.status}`)
].join('|')
return {
lead: '补充信息:',
tone: 'danger',
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature, {
savedDraft
}),
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
notes: []
}
}
return {
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
tone: 'neutral',
summary: '',
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, false)),
notes: [
reviewPayload?.can_proceed ? '确认无误后,可以继续下一步。' : '',
riskBriefs.length ? `系统同时保留了 ${riskBriefs.length} 条风险提醒,请在提交前核查。` : ''
].filter(Boolean)
}
}
export function resolveReviewPrimaryAction(reviewPayload) {
return (
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
(item) => item.emphasis === 'primary' || ['save_draft', 'next_step'].includes(String(item?.action_type || ''))
) || null
)
}
export function resolveReviewSaveDraftAction(reviewPayload) {
return (
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
(item) => String(item?.action_type || '') === 'save_draft'
) || null
)
}
export function resolveReviewFooterActions(reviewPayload) {
return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => {
const actionType = String(item?.action_type || '').trim()
return ['link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)
})
}
export function buildReviewRiskLevelCounts(reviewPayload) {
return (Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []).reduce(
(counts, item) => {
const level = normalizeReviewRiskLevel(item?.level)
if (level === 'high' || level === 'medium' || level === 'low') {
counts[level] += 1
}
return counts
},
{ low: 0, medium: 0, high: 0 }
)
}
export function resolveReviewNextStepAction(reviewPayload) {
return (
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
(item) => String(item?.action_type || '').trim() === 'next_step'
) || null
)
}
export function buildReviewNextStepRichCopy(reviewPayload, { detailHref = '' } = {}) {
const nextStepAction = resolveReviewNextStepAction(reviewPayload)
if (!nextStepAction) {
return ''
}
const counts = buildReviewRiskLevelCounts(reviewPayload)
const riskSummary = `现存在 ${counts.low} 条低风险,${counts.medium} 条中风险,${counts.high} 条高风险,具体情况请看 [右侧](#review-risk-panel) 风险信息提示窗。`
const lines = [`系统识别您的单据已经填写完所有已知信息,${riskSummary}`]
if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) {
const editHref = String(detailHref || '').trim() || '#review-quick-edit'
lines.push(
`系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果你还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。`
)
}
return lines.join('\n\n')
}
export function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) {
const action = resolveReviewPrimaryAction(reviewPayload)
if (!action) return '确认'
if (action.action_type === 'save_draft') {
return draftPayload?.claim_no ? '保存为草稿' : '保存为草稿'
}
if (action.action_type === 'next_step') {
return '继续下一步'
}
if (action.action_type === 'link_to_existing_draft') {
return action.label || '关联到现有草稿'
}
if (action.action_type === 'create_new_claim_from_documents') {
return action.label || '单独建立报销单'
}
return action.label || '确认'
}
export function buildReviewIntentText(reviewPayload) {
const slotMap = buildReviewSlotMap(reviewPayload)
const expenseType = String(slotMap.expense_type?.value || '').trim()
if (expenseType) {
return `报销一笔${expenseType}`
}
return '发起一笔报销'
}
export function buildReviewSceneValue(reviewPayload) {
const slotMap = buildReviewSlotMap(reviewPayload)
const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim()
const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim()
return inferPresetSceneFromReview(reviewPayload, reason, expenseType)
}
export function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
if (slotKey === 'customer_name') {
return expenseTypeLabel === '业务招待费'
? '业务招待费需补充客户单位名称,以便进行合规校验。'
: '当前仍缺少客户单位名称,建议补充后再提交。'
}
if (slotKey === 'participants') {
return '缺少同行人员信息,建议补充至少 1 名。'
}
if (slotKey === 'attachments') {
return '尚未上传票据附件,当前无法完成票据核对。'
}
if (slotKey === 'amount') {
return '报销金额仍待确认,提交前需补齐金额信息。'
}
if (slotKey === 'time_range') {
return '业务发生时间仍待确认,建议补充准确日期。'
}
if (slotKey === 'reason') {
return '报销事由说明仍不完整,建议补充业务背景。'
}
return '当前仍有识别信息待补充,建议先核对后再处理。'
}
export function buildReviewRiskSummary(reviewPayload, riskBriefResolver = null) {
if (resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver).length) {
return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。'
}
return '当前没有需要额外处理的结构化风险点。'
}
export function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
if (normalized === 'info' || normalized === 'notice') return 'info'
if (normalized === 'low') return 'low'
if (normalized === 'high') return normalized
return 'low'
}
export function buildLocalReviewCompletionMessage(reviewPayload) {
const missingSlots = Array.isArray(reviewPayload?.missing_slots) ? reviewPayload.missing_slots : []
if (reviewPayload?.can_proceed && !missingSlots.length) {
return '当前所有必填信息已处理完成,可以点击“继续下一步”进入 AI 预审。'
}
if (missingSlots.length) {
return `当前还剩 ${missingSlots.length} 项待补充:${missingSlots.join('、')}`
}
return '当前信息已保存,可以继续核对右侧状态。'
}
export function buildReviewRecognitionNotes(reviewPayload) {
const recognized = resolveReviewRecognizedSlotCards(reviewPayload)
const notes = []
const timeSlot = recognized.find((item) => item.key === 'time_range')
const sourceLabels = [...new Set(recognized.map((item) => String(item?.source_label || '').trim()).filter(Boolean))]
if (timeSlot?.raw_value && timeSlot.raw_value !== timeSlot.value && timeSlot.value) {
notes.push(`时间已按你的本地日期换算:${timeSlot.raw_value} -> ${timeSlot.value}`)
}
if (sourceLabels.length) {
notes.push(`本轮主要依据:${sourceLabels.join('、')}`)
}
const documentCards = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
if (documentCards.length) {
notes.push(`已关联 ${documentCards.length} 份附件,逐张识别结果已整理在下方`)
} else {
notes.push('当前还没有上传票据,这一轮主要依据你的文字描述完成初步识别')
}
return notes
}
export function buildReviewMissingHint(reviewPayload) {
if (!countReviewPendingItems(reviewPayload)) {
return ''
}
if (reviewPayload?.can_proceed) {
return '当前关键信息已经齐全,这里无需再补充。'
}
return '下面这些字段还需要你再确认或补齐,补完后我就能继续往下处理。'
}
export function buildReviewRiskHint(reviewPayload, riskBriefResolver = null) {
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload, riskBriefResolver)
if (!riskBriefs.length) {
return ''
}
return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。'
}
export function buildReviewActionHint(reviewPayload) {
if (reviewPayload?.can_proceed) {
return '如果识别无误,可以继续下一步;如果有偏差,请直接在右侧核对信息中修改。'
}
return '如果现在信息还不完整,可以先保存草稿;识别错了请直接在右侧核对信息中修改。'
}
export function buildReviewStatusTag(reviewPayload) {
const missingCount = countReviewPendingItems(reviewPayload)
if (reviewPayload?.can_proceed) {
return '可继续处理'
}
if (missingCount > 0) {
return `待补充 ${missingCount}`
}
return '待确认'
}