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 '待确认' }