2026-05-21 23:53:03 +08:00
|
|
|
|
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 ||
|
|
|
|
|
|
''
|
|
|
|
|
|
)
|
2026-05-22 23:47:28 +08:00
|
|
|
|
if (['travel', 'hotel'].includes(expenseType)) {
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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 (
|
2026-05-22 23:47:28 +08:00
|
|
|
|
['flight_itinerary', 'train_ticket', 'hotel_invoice'].includes(documentType) ||
|
|
|
|
|
|
['travel', 'hotel'].includes(suggestedType)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-22 08:58:59 +08:00
|
|
|
|
? 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)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
: []
|
|
|
|
|
|
if (!labels.length) return []
|
|
|
|
|
|
|
|
|
|
|
|
const slotLabels = new Set(
|
2026-05-22 08:58:59 +08:00
|
|
|
|
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : []).flatMap((item) => [
|
|
|
|
|
|
String(item?.label || '').trim(),
|
|
|
|
|
|
String(item?.key || '').trim()
|
|
|
|
|
|
]).filter(Boolean)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
)
|
|
|
|
|
|
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,
|
2026-05-22 23:47:28 +08:00
|
|
|
|
edit_fields: mergeInlineReviewFields(reviewPayload.edit_fields || [], inlineState),
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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 || '已识别,请核查是否准确。'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 08:58:59 +08:00
|
|
|
|
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},请先检查下面的补充项;想先留存当前识别结果时可以点“草稿”。`
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
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}。可以继续补充票据和字段,系统会围绕已保存草稿继续更新。`
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-05-22 08:58:59 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
function buildReviewPendingSummary(pendingCount, riskCount, signature = '', options = {}) {
|
2026-05-22 08:58:59 +08:00
|
|
|
|
const issueParts = []
|
|
|
|
|
|
if (pendingCount) {
|
|
|
|
|
|
issueParts.push(`${pendingCount} 项信息待补充`)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (riskCount) {
|
|
|
|
|
|
issueParts.push(`${riskCount} 条风险提醒`)
|
|
|
|
|
|
}
|
|
|
|
|
|
const issueSummary = issueParts.length ? issueParts.join('、') : '一些细节还需要进一步确认'
|
2026-05-22 16:00:19 +08:00
|
|
|
|
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 })
|
2026-05-22 08:58:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 16:00:19 +08:00
|
|
|
|
export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
|
|
|
|
|
|
const savedDraft = Boolean(options?.savedDraft)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
const todoItems = buildReviewTodoItems(reviewPayload)
|
|
|
|
|
|
const pendingCount = countReviewPendingItems(reviewPayload)
|
|
|
|
|
|
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
|
2026-05-22 08:58:59 +08:00
|
|
|
|
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
|
|
|
|
|
|
|
|
|
|
|
|
if (pendingCount || extraMissingCount) {
|
|
|
|
|
|
const summarySignature = [
|
|
|
|
|
|
pendingCount || extraMissingCount,
|
|
|
|
|
|
riskBriefs.length,
|
|
|
|
|
|
...todoItems.map((item) => `${item.key}:${item.title}:${item.status}`)
|
|
|
|
|
|
].join('|')
|
2026-05-21 23:53:03 +08:00
|
|
|
|
return {
|
2026-05-22 08:58:59 +08:00
|
|
|
|
lead: '补充信息:',
|
|
|
|
|
|
tone: 'danger',
|
2026-05-22 16:00:19 +08:00
|
|
|
|
summary: buildReviewPendingSummary(pendingCount || extraMissingCount, riskBriefs.length, summarySignature, {
|
|
|
|
|
|
savedDraft
|
|
|
|
|
|
}),
|
2026-05-21 23:53:03 +08:00
|
|
|
|
items: todoItems.map((item) => buildReviewPlainFollowupItem(item, true)),
|
2026-05-22 08:58:59 +08:00
|
|
|
|
notes: []
|
2026-05-21 23:53:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
lead: todoItems.length ? '我已整理出当前识别到的关键信息:' : '当前关键信息已基本整理完成。',
|
2026-05-22 08:58:59 +08:00
|
|
|
|
tone: 'neutral',
|
|
|
|
|
|
summary: '',
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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()
|
2026-05-22 23:47:28 +08:00
|
|
|
|
return ['link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)
|
2026-05-21 23:53:03 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 23:47:28 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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'
|
2026-05-22 23:47:28 +08:00
|
|
|
|
if (normalized === 'info' || normalized === 'notice') return 'info'
|
|
|
|
|
|
if (normalized === 'low') return 'low'
|
2026-05-21 23:53:03 +08:00
|
|
|
|
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 '待确认'
|
|
|
|
|
|
}
|