feat: 重构报销单AI预审流程并添加平台风险规则引擎
- 将AI验审改为AI预审,高风险不再拦截而是随单流转给审批人复核 - 新增平台风险规则评估引擎,支持事由过短、票据异常、重复发票等多种评估器 - 用户上下文增加部门信息(department_name),认证流程同步关联组织架构 - 规则scenario_json改为中文标签(差旅/费用科目),统一场景分类 - 新增orchestrator审核流程测试用例 - 前端更新审计视图、差旅报销等相关页面
This commit is contained in:
@@ -91,7 +91,7 @@ function resolveRiskItems(request) {
|
||||
|
||||
return [
|
||||
{
|
||||
text: 'AI验审已通过,当前未发现额外风险。',
|
||||
text: 'AI预审已通过,当前未发现额外风险。',
|
||||
level: '低',
|
||||
tone: 'low',
|
||||
icon: 'mdi mdi-shield-check'
|
||||
|
||||
@@ -303,6 +303,8 @@ const RISK_SCENARIO_OPTIONS = [
|
||||
{ value: '通用', label: '通用' }
|
||||
]
|
||||
|
||||
const RISK_SCENARIO_VALUES = new Set(RISK_SCENARIO_OPTIONS.map((item) => item.value).filter(Boolean))
|
||||
|
||||
const LEGACY_RISK_SCENARIO_KEYS = new Set([
|
||||
'expense',
|
||||
'risk_check',
|
||||
@@ -313,7 +315,10 @@ const LEGACY_RISK_SCENARIO_KEYS = new Set([
|
||||
'travel_standard',
|
||||
'attachment_policy',
|
||||
'scene_policy',
|
||||
'invoice_anomaly'
|
||||
'invoice_anomaly',
|
||||
'communication_expense',
|
||||
'expense_standard',
|
||||
'approval_required'
|
||||
])
|
||||
|
||||
const SPREADSHEET_DETAIL_MODE = 'spreadsheet'
|
||||
@@ -409,7 +414,7 @@ function createPreviewRuleDetailPayload() {
|
||||
name: '公司差旅费报销规则',
|
||||
description: '前端预览态:先展示 Excel 规则详情页布局、版本卡片和编辑入口位置。',
|
||||
domain: 'expense',
|
||||
scenario_json: ['expense', 'travel_policy', 'travel_standard'],
|
||||
scenario_json: ['差旅'],
|
||||
owner: '财务制度管理组',
|
||||
reviewer: '顾承宇',
|
||||
status: 'active',
|
||||
@@ -422,6 +427,8 @@ function createPreviewRuleDetailPayload() {
|
||||
tag: '财务规则',
|
||||
detail_mode: 'spreadsheet',
|
||||
runtime_kind: 'travel_policy',
|
||||
scenario_category: '差旅',
|
||||
ai_review_category: '差旅',
|
||||
rule_template_label: '差旅报销 Excel 模板',
|
||||
rule_document: {
|
||||
...currentMeta,
|
||||
@@ -588,26 +595,37 @@ function inferRiskCategoryFromCode(code) {
|
||||
return '通用'
|
||||
}
|
||||
|
||||
function normalizeRiskScenarioCategory(value) {
|
||||
const normalized = normalizeText(value)
|
||||
return RISK_SCENARIO_VALUES.has(normalized) ? normalized : ''
|
||||
}
|
||||
|
||||
function readScenarioItems(source) {
|
||||
if (Array.isArray(source?.scenario_json)) {
|
||||
return source.scenario_json
|
||||
}
|
||||
if (Array.isArray(source?.scenarioList)) {
|
||||
return source.scenarioList
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function resolveRiskRuleCategory(source) {
|
||||
const configJson = readConfigJson(source)
|
||||
const explicit = normalizeText(configJson.risk_category)
|
||||
const explicit = normalizeRiskScenarioCategory(configJson.risk_category)
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const payloadCategory = normalizeText(source?.risk_category)
|
||||
const payloadCategory = normalizeRiskScenarioCategory(source?.risk_category)
|
||||
if (payloadCategory) {
|
||||
return payloadCategory
|
||||
}
|
||||
|
||||
const scenarioItems = Array.isArray(source?.scenario_json)
|
||||
? source.scenario_json
|
||||
: Array.isArray(source?.scenarioList)
|
||||
? source.scenarioList
|
||||
: []
|
||||
const scenarioItems = readScenarioItems(source)
|
||||
const businessScenario = scenarioItems
|
||||
.map((item) => normalizeText(item))
|
||||
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item))
|
||||
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item) && RISK_SCENARIO_VALUES.has(item))
|
||||
if (businessScenario) {
|
||||
return businessScenario
|
||||
}
|
||||
@@ -615,6 +633,75 @@ function resolveRiskRuleCategory(source) {
|
||||
return inferRiskCategoryFromCode(source?.code)
|
||||
}
|
||||
|
||||
function inferFinancialRuleCategory(source) {
|
||||
const configJson = readConfigJson(source)
|
||||
const explicit =
|
||||
normalizeRiskScenarioCategory(configJson.scenario_category) ||
|
||||
normalizeRiskScenarioCategory(configJson.ai_review_category) ||
|
||||
normalizeRiskScenarioCategory(configJson.risk_category) ||
|
||||
normalizeRiskScenarioCategory(source?.scenario_category) ||
|
||||
normalizeRiskScenarioCategory(source?.risk_category)
|
||||
if (explicit) {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const scenarioCategory = readScenarioItems(source)
|
||||
.map((item) => normalizeRiskScenarioCategory(item))
|
||||
.find(Boolean)
|
||||
if (scenarioCategory) {
|
||||
return scenarioCategory
|
||||
}
|
||||
|
||||
const configRuntimeRule = isPlainObject(configJson.runtime_rule) ? configJson.runtime_rule : {}
|
||||
const haystack = [
|
||||
source?.code,
|
||||
source?.name,
|
||||
source?.description,
|
||||
configJson.runtime_kind,
|
||||
configRuntimeRule.kind,
|
||||
configRuntimeRule.scenario,
|
||||
configRuntimeRule.template_key,
|
||||
...readScenarioItems(source)
|
||||
]
|
||||
.map((item) => normalizeText(item).toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
if (!haystack) {
|
||||
return '通用'
|
||||
}
|
||||
if (/(travel|trip|差旅|出差|住宿|酒店)/i.test(haystack)) {
|
||||
return '差旅'
|
||||
}
|
||||
if (/(invoice|receipt|attachment|票据|发票|单据|附件)/i.test(haystack)) {
|
||||
return '发票'
|
||||
}
|
||||
if (/(meal|dining|entertainment|餐饮|招待|餐费|用餐)/i.test(haystack)) {
|
||||
return '餐饮招待'
|
||||
}
|
||||
if (/(transport|traffic|taxi|交通|出行|打车|机票|火车|高铁|地铁|公交)/i.test(haystack)) {
|
||||
return '交通出行'
|
||||
}
|
||||
if (/(office|material|suppl|办公|物料|耗材)/i.test(haystack)) {
|
||||
return '办公物料'
|
||||
}
|
||||
if (/(communication|telecom|phone|expense_standard|费用科目|费用标准|通信|通讯|手机|补贴|福利|科目)/i.test(haystack)) {
|
||||
return '费用科目'
|
||||
}
|
||||
return '通用'
|
||||
}
|
||||
|
||||
function resolveRuleScenarioCategory(source, tabId = '') {
|
||||
const resolvedTabId = tabId || resolveRuleTabId(source)
|
||||
if (resolvedTabId === 'riskRules' || isJsonRiskRuleSource(source)) {
|
||||
return resolveRiskRuleCategory(source)
|
||||
}
|
||||
if (resolvedTabId === 'financialRules') {
|
||||
return inferFinancialRuleCategory(source)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function buildRiskListSubtitle(text, maxLength = 42) {
|
||||
const normalized = normalizeText(text)
|
||||
if (!normalized) {
|
||||
@@ -1006,7 +1093,7 @@ function buildListItem(asset) {
|
||||
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
|
||||
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
|
||||
const ruleDocument = readRuleDocumentMeta(asset)
|
||||
const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : ''
|
||||
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(asset, tabId) : ''
|
||||
const listSubtitle = isRiskRule
|
||||
? buildRiskListSubtitle(asset.description)
|
||||
: normalizeText(asset.description)
|
||||
@@ -1028,8 +1115,8 @@ function buildListItem(asset) {
|
||||
category: resolveDomainLabel(asset.domain),
|
||||
owner: asset.owner,
|
||||
reviewer: asset.reviewer || '待分配',
|
||||
scope: isRiskRule ? riskCategory || '通用' : formatScenarioList(asset.scenario_json),
|
||||
riskCategory,
|
||||
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(asset.scenario_json),
|
||||
riskCategory: ruleScenarioCategory,
|
||||
model: buildRowRuntime(asset, typeKey),
|
||||
version: workingVersion,
|
||||
versionDisplay: typeKey === 'rules' ? `${changeCount} 次` : workingVersion,
|
||||
@@ -1050,6 +1137,7 @@ function buildListItem(asset) {
|
||||
|
||||
function buildRuleFields(detail) {
|
||||
const ruleDocument = readRuleDocumentMeta(detail)
|
||||
const ruleScenarioCategory = resolveRuleScenarioCategory(detail)
|
||||
return [
|
||||
{ label: '规则编码', value: detail.code },
|
||||
{
|
||||
@@ -1073,7 +1161,7 @@ function buildRuleFields(detail) {
|
||||
label: '运行时类型',
|
||||
value: normalizeText(detail.config_json?.runtime_kind) || 'policy_rule_draft'
|
||||
},
|
||||
{ label: '适用场景', value: formatScenarioList(detail.scenario_json) },
|
||||
{ label: '适用场景', value: ruleScenarioCategory || '通用' },
|
||||
{ label: '线上版本', value: detail.published_version || '-' },
|
||||
{ label: '工作版本', value: detail.working_version || detail.current_version || '-' }
|
||||
]
|
||||
@@ -1417,6 +1505,7 @@ function buildDetailViewModel(detail, runs) {
|
||||
const ruleTemplateKey = normalizeText(configJson.rule_template_key || previewRuntimeRule.template_key)
|
||||
const ruleTemplateLabel = normalizeText(configJson.rule_template_label) || resolveRuleTemplateLabel(ruleTemplateKey)
|
||||
const runtimeKind = normalizeText(configJson.runtime_kind || previewRuntimeRule.kind) || 'policy_rule_draft'
|
||||
const ruleScenarioCategory = typeKey === 'rules' ? resolveRuleScenarioCategory(detail, tabId) : ''
|
||||
|
||||
return {
|
||||
id: detail.id,
|
||||
@@ -1431,7 +1520,7 @@ function buildDetailViewModel(detail, runs) {
|
||||
owner: detail.owner,
|
||||
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
|
||||
category: resolveDomainLabel(detail.domain),
|
||||
scope: usesJsonRiskRule ? resolveRiskRuleCategory(detail) || '通用' : formatScenarioList(detail.scenario_json),
|
||||
scope: typeKey === 'rules' ? ruleScenarioCategory || '通用' : formatScenarioList(detail.scenario_json),
|
||||
version: detail.working_version || detail.current_version || '-',
|
||||
currentVersion: detail.current_version || '-',
|
||||
publishedVersion: detail.published_version || '-',
|
||||
@@ -1451,9 +1540,13 @@ function buildDetailViewModel(detail, runs) {
|
||||
riskRuleDescription: '',
|
||||
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
|
||||
riskRuleSourceRef: '',
|
||||
riskCategory: usesJsonRiskRule ? resolveRiskRuleCategory(detail) : '',
|
||||
riskCategory: typeKey === 'rules' ? ruleScenarioCategory : '',
|
||||
ruleDocument,
|
||||
scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [],
|
||||
scenarioList: typeKey === 'rules' && ruleScenarioCategory
|
||||
? [ruleScenarioCategory]
|
||||
: Array.isArray(detail.scenario_json)
|
||||
? [...detail.scenario_json]
|
||||
: [],
|
||||
markdownContent: previewMarkdown,
|
||||
runtimeRuleText: stringifyRuntimeRule(previewRuntimeRule),
|
||||
ruleTemplateKey,
|
||||
@@ -1474,7 +1567,12 @@ function buildDetailViewModel(detail, runs) {
|
||||
typeKey === 'rules' ? [] : buildPromptSections(detail, typeKey, latestRun, latestCall),
|
||||
outputRules: buildOutputRules(detail, typeKey, latestRun, latestCall),
|
||||
tests: buildTests(detail, typeKey, latestRun, latestCall),
|
||||
triggers: detail.scenario_json?.length ? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item) : ['未配置场景'],
|
||||
triggers:
|
||||
typeKey === 'rules'
|
||||
? [ruleScenarioCategory || '通用']
|
||||
: detail.scenario_json?.length
|
||||
? detail.scenario_json.map((item) => SCENARIO_LABELS[item] || item)
|
||||
: ['未配置场景'],
|
||||
tools:
|
||||
typeKey === 'rules'
|
||||
? [
|
||||
@@ -1769,7 +1867,9 @@ export default {
|
||||
const selectedStatusLabel = computed(
|
||||
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
|
||||
)
|
||||
const showRiskScenarioFilter = computed(() => activeType.value === 'riskRules')
|
||||
const showRiskScenarioFilter = computed(() =>
|
||||
['financialRules', 'riskRules'].includes(activeType.value)
|
||||
)
|
||||
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
|
||||
const selectedRiskScenarioLabel = computed(
|
||||
() =>
|
||||
@@ -1799,6 +1899,13 @@ export default {
|
||||
})
|
||||
const auditEmptyState = computed(() => {
|
||||
const hasFilters = activeFilterTokens.value.length > 0
|
||||
const supportedFilters = [
|
||||
'业务域',
|
||||
'负责人',
|
||||
...(showRiskScenarioFilter.value ? ['使用场景'] : []),
|
||||
...(showStatusFilter.value ? ['状态'] : []),
|
||||
'关键词'
|
||||
]
|
||||
|
||||
if (!currentAssets.value.length) {
|
||||
return {
|
||||
@@ -1810,10 +1917,10 @@ export default {
|
||||
actionIcon: '',
|
||||
tone: 'amber',
|
||||
artLabel: 'ASSET',
|
||||
tips:
|
||||
activeType.value === 'riskRules'
|
||||
? ['切换页签可查看其他资产类型', '支持按业务域、负责人和使用场景做过滤']
|
||||
: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤']
|
||||
tips: [
|
||||
'切换页签可查看其他资产类型',
|
||||
`支持按${supportedFilters.slice(0, -1).join('、')}和关键词做过滤`
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1821,9 +1928,7 @@ export default {
|
||||
eyebrow: '筛选结果为空',
|
||||
title: `没有找到匹配的${activeTabLabel.value}`,
|
||||
desc: hasFilters
|
||||
? showRiskScenarioFilter.value
|
||||
? '试试清空业务域、负责人、使用场景或关键词筛选,再重新查看。'
|
||||
: '试试清空业务域、负责人、状态或关键词筛选,再重新查看。'
|
||||
? `试试清空${supportedFilters.join('、')}筛选,再重新查看。`
|
||||
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
|
||||
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
|
||||
actionLabel: hasFilters ? '清空筛选' : '',
|
||||
@@ -1831,9 +1936,12 @@ export default {
|
||||
tone: hasFilters ? 'emerald' : 'slate',
|
||||
artLabel: hasFilters ? 'FILTER' : 'QUEUE',
|
||||
tips: hasFilters
|
||||
? showRiskScenarioFilter.value
|
||||
? ['业务域、负责人、使用场景与关键词会叠加过滤', '可以换个规则名称或场景分类继续搜索']
|
||||
: ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索']
|
||||
? [
|
||||
`${supportedFilters.join('、')}会叠加过滤`,
|
||||
showRiskScenarioFilter.value
|
||||
? '可以换个规则名称或场景分类继续搜索'
|
||||
: '可以换个编码、名称或负责人关键词继续搜索'
|
||||
]
|
||||
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
|
||||
}
|
||||
})
|
||||
|
||||
@@ -209,17 +209,11 @@ const FLOW_STEP_FALLBACKS = {
|
||||
runningText: '正在识别票据附件...',
|
||||
completedText: '票据识别完成'
|
||||
},
|
||||
agent: {
|
||||
title: '智能体编排',
|
||||
tool: 'UserAgent',
|
||||
runningText: '正在调用财务智能体...',
|
||||
completedText: '智能体处理完成'
|
||||
},
|
||||
result: {
|
||||
title: '生成结果',
|
||||
tool: 'ResultGenerator',
|
||||
runningText: '正在生成解释与草稿...',
|
||||
completedText: '结果已生成'
|
||||
'expense-claim-draft': {
|
||||
title: '报销草稿处理',
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
runningText: '正在根据识别结果更新草稿和右侧核对信息...',
|
||||
completedText: '草稿和核对信息已更新'
|
||||
}
|
||||
}
|
||||
const ASSISTANT_DISPLAY_NAME = '财务助手'
|
||||
@@ -345,41 +339,6 @@ function formatMessageTime(value) {
|
||||
})
|
||||
}
|
||||
|
||||
function createFlowSteps(options = {}) {
|
||||
const keys = []
|
||||
if (options.includeIntent) {
|
||||
keys.push('intent')
|
||||
}
|
||||
if (options.includeOcr) {
|
||||
keys.push('ocr')
|
||||
}
|
||||
if (options.includeExtraction) {
|
||||
keys.push('extraction')
|
||||
}
|
||||
if (options.includeAgent) {
|
||||
keys.push('agent')
|
||||
}
|
||||
if (options.includeResult) {
|
||||
keys.push('result')
|
||||
}
|
||||
|
||||
return keys.map((key, index) => {
|
||||
const definition = FLOW_STEP_FALLBACKS[key] || {}
|
||||
return {
|
||||
key,
|
||||
index: index + 1,
|
||||
title: definition.title || '智能体工具调用',
|
||||
tool: definition.tool || 'AgentTool',
|
||||
status: FLOW_STEP_STATUS_PENDING,
|
||||
detail: '',
|
||||
durationMs: null,
|
||||
startedAt: 0,
|
||||
finishedAt: 0,
|
||||
error: ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatSemanticEntityValue(entity) {
|
||||
const normalizedValue = String(entity?.normalized_value || '').trim()
|
||||
const rawValue = String(entity?.value || '').trim()
|
||||
@@ -577,11 +536,8 @@ function formatFlowDuration(ms) {
|
||||
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||
return '--'
|
||||
}
|
||||
if (numericValue < 100) {
|
||||
return '<0.1s'
|
||||
}
|
||||
if (numericValue < 1000) {
|
||||
return `${(numericValue / 1000).toFixed(1)}s`
|
||||
return `${Math.max(0.1, numericValue / 1000).toFixed(1)}s`
|
||||
}
|
||||
if (numericValue < 10000) {
|
||||
return `${(numericValue / 1000).toFixed(1)}s`
|
||||
@@ -639,34 +595,6 @@ function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
|
||||
return finishedAt - startedAt
|
||||
}
|
||||
|
||||
function resolveResultStepDurationMs(run) {
|
||||
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
|
||||
if (!runFinishedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||
const semanticFinishedAt = (
|
||||
toolCalls
|
||||
.map((item, index) => {
|
||||
const startedAt = parseFlowTimestamp(item?.created_at)
|
||||
const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run)
|
||||
if (!startedAt || !durationMs) {
|
||||
return 0
|
||||
}
|
||||
return startedAt + durationMs
|
||||
})
|
||||
.filter((value) => value > 0)
|
||||
.sort((left, right) => right - left)[0]
|
||||
) || parseFlowTimestamp(run?.started_at)
|
||||
|
||||
if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) {
|
||||
return null
|
||||
}
|
||||
|
||||
return runFinishedAt - semanticFinishedAt
|
||||
}
|
||||
|
||||
function sanitizeRequest(request) {
|
||||
if (!request || typeof request !== 'object') return null
|
||||
|
||||
@@ -1559,7 +1487,7 @@ function buildDraftSavedPayload({
|
||||
status: String(draftPayload?.status || '').trim(),
|
||||
approvalStage: String(draftPayload?.approval_stage || '').trim(),
|
||||
person: String(currentUser?.name || '').trim() || '当前用户',
|
||||
dept: String(currentUser?.role || '').trim() || '待补充部门',
|
||||
dept: String(currentUser?.department || currentUser?.departmentName || '').trim() || '待补充部门',
|
||||
entity: String(linkedRequest?.entity || '').trim() || 'Northstar China Ltd.',
|
||||
typeCode,
|
||||
typeLabel,
|
||||
@@ -2525,11 +2453,103 @@ function buildReviewRiskSummary(reviewPayload) {
|
||||
|
||||
function buildReviewRiskItems(reviewPayload) {
|
||||
return resolveReviewRiskBriefs(reviewPayload)
|
||||
.map((brief) => String(brief?.content || '').trim())
|
||||
.map((brief) => {
|
||||
const title = String(brief?.title || '').trim()
|
||||
const content = String(brief?.content || '').trim()
|
||||
if (title && content) return `${title}:${content}`
|
||||
return content || title
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
}
|
||||
|
||||
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 ''
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return [
|
||||
...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())),
|
||||
{
|
||||
label: '继续下一步',
|
||||
action_type: 'next_step',
|
||||
description: '当前信息已齐全,进入 AI 预审、风险校验和审批路径确认。',
|
||||
emphasis: 'primary'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
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 canProceed = missingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
|
||||
|
||||
return {
|
||||
...reviewPayload,
|
||||
can_proceed: canProceed,
|
||||
missing_slots: missingSlots,
|
||||
slot_cards: nextSlotCards,
|
||||
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
|
||||
}
|
||||
}
|
||||
|
||||
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 '当前信息已保存,可以继续核对右侧状态。'
|
||||
}
|
||||
|
||||
function normalizeInlineReviewComparableState(state) {
|
||||
const source = state && typeof state === 'object' ? state : {}
|
||||
return {
|
||||
@@ -2583,6 +2603,49 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
|
||||
return lines
|
||||
}
|
||||
|
||||
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: '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
|
||||
}
|
||||
|
||||
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(',')}。`
|
||||
}
|
||||
|
||||
function buildInlineReviewUserText(baseState, nextState, pendingFiles = []) {
|
||||
const lines = buildInlineReviewChangedLines(baseState, nextState, pendingFiles)
|
||||
if (!lines.length) {
|
||||
@@ -2894,7 +2957,7 @@ export default {
|
||||
const flowRunId = ref('')
|
||||
const flowStartedAt = ref(0)
|
||||
const flowFinishedAt = ref(0)
|
||||
const flowSteps = ref(createFlowSteps())
|
||||
const flowSteps = ref([])
|
||||
const flowRefreshBusy = ref(false)
|
||||
const flowTick = ref(Date.now())
|
||||
let flowTickTimer = 0
|
||||
@@ -3415,7 +3478,7 @@ export default {
|
||||
flowRunId.value = ''
|
||||
flowStartedAt.value = 0
|
||||
flowFinishedAt.value = 0
|
||||
flowSteps.value = createFlowSteps()
|
||||
flowSteps.value = []
|
||||
}
|
||||
|
||||
function adjustComposerTextareaHeight() {
|
||||
@@ -3454,22 +3517,14 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
function resetFlowRun(options = {}) {
|
||||
function resetFlowRun() {
|
||||
clearFlowSimulationTimers()
|
||||
flowRunId.value = ''
|
||||
flowStartedAt.value = Date.now()
|
||||
flowFinishedAt.value = 0
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_FLOW
|
||||
insightPanelCollapsed.value = false
|
||||
const hasText = Boolean(String(options.rawText || '').trim())
|
||||
const attachmentCount = Number(options.attachmentCount || 0)
|
||||
flowSteps.value = createFlowSteps({
|
||||
includeIntent: hasText,
|
||||
includeOcr: attachmentCount > 0,
|
||||
includeExtraction: hasText || attachmentCount > 0,
|
||||
includeAgent: true,
|
||||
includeResult: true
|
||||
})
|
||||
flowSteps.value = []
|
||||
}
|
||||
|
||||
function findFlowDefinition(key) {
|
||||
@@ -3511,13 +3566,6 @@ export default {
|
||||
const existingStep = flowSteps.value.find((step) => step.key === key)
|
||||
if (!existingStep) {
|
||||
const nextStep = createFlowStep(key, patch)
|
||||
const resultIndex = flowSteps.value.findIndex((step) => step.key === 'result')
|
||||
if (resultIndex !== -1 && key !== 'result') {
|
||||
const nextSteps = [...flowSteps.value]
|
||||
nextSteps.splice(resultIndex, 0, nextStep)
|
||||
flowSteps.value = normalizeFlowStepIndexes(nextSteps)
|
||||
return
|
||||
}
|
||||
flowSteps.value = normalizeFlowStepIndexes([...flowSteps.value, nextStep])
|
||||
return
|
||||
}
|
||||
@@ -3529,11 +3577,15 @@ export default {
|
||||
|
||||
function startFlowStep(key, patch = {}) {
|
||||
const normalizedPatch = normalizeFlowStepPatch(key, patch)
|
||||
const explicitStartedAt = Number(normalizedPatch.startedAt)
|
||||
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
||||
? explicitStartedAt
|
||||
: Date.now()
|
||||
upsertFlowStep(key, {
|
||||
...normalizedPatch,
|
||||
status: FLOW_STEP_STATUS_RUNNING,
|
||||
detail: normalizedPatch.detail,
|
||||
startedAt: Date.now(),
|
||||
startedAt,
|
||||
finishedAt: 0,
|
||||
durationMs: null,
|
||||
error: ''
|
||||
@@ -3544,14 +3596,16 @@ export default {
|
||||
const now = Date.now()
|
||||
const definition = findFlowDefinition(key)
|
||||
const currentStep = flowSteps.value.find((step) => step.key === key)
|
||||
const startedAt = currentStep?.startedAt || now
|
||||
const explicitDuration = Number(durationMs)
|
||||
const hasExplicitDuration = Number.isFinite(explicitDuration) && explicitDuration >= 0
|
||||
const startedAt = currentStep?.startedAt || (hasExplicitDuration ? Math.max(0, now - explicitDuration) : now)
|
||||
upsertFlowStep(key, {
|
||||
...patch,
|
||||
status: FLOW_STEP_STATUS_COMPLETED,
|
||||
detail: detail || definition?.completedText || '',
|
||||
startedAt,
|
||||
finishedAt: now,
|
||||
durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt,
|
||||
durationMs: hasExplicitDuration ? explicitDuration : Math.max(0, now - startedAt),
|
||||
error: ''
|
||||
})
|
||||
}
|
||||
@@ -3601,7 +3655,12 @@ export default {
|
||||
function failCurrentFlowStep(error) {
|
||||
clearFlowSimulationTimers()
|
||||
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
|
||||
failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '')
|
||||
failFlowStep(
|
||||
currentStep?.key || 'orchestrator-error',
|
||||
error?.message || '智能体调用失败',
|
||||
error?.message || '',
|
||||
currentStep ? {} : { title: '流程调用', tool: 'Orchestrator' }
|
||||
)
|
||||
}
|
||||
|
||||
function startSemanticFlowPreview(rawText, options = {}) {
|
||||
@@ -3646,9 +3705,63 @@ export default {
|
||||
flowSimulationTimers.push(startExtractionTimer)
|
||||
}
|
||||
|
||||
function startReviewActionFlowStep(reviewAction) {
|
||||
if (reviewAction !== 'next_step') {
|
||||
return
|
||||
}
|
||||
|
||||
startFlowStep('pre-submit-review', {
|
||||
title: 'AI预审与风险识别',
|
||||
tool: 'ExpenseClaimService.submit_claim',
|
||||
detail: '正在校验财务规则、风险规则和审批路径...'
|
||||
})
|
||||
}
|
||||
|
||||
function startExpenseClaimDraftFlowStep(reviewAction, options = {}) {
|
||||
if (isKnowledgeSession.value) {
|
||||
return
|
||||
}
|
||||
if (reviewAction === 'next_step') {
|
||||
startReviewActionFlowStep(reviewAction)
|
||||
return
|
||||
}
|
||||
|
||||
const attachmentCount = Math.max(0, Number(options.attachmentCount || 0))
|
||||
const configs = {
|
||||
save_draft: {
|
||||
title: '报销草稿保存',
|
||||
detail: '正在保存当前核对结果...'
|
||||
},
|
||||
link_to_existing_draft: {
|
||||
title: '票据关联草稿',
|
||||
detail: '正在把本次票据关联到现有草稿...'
|
||||
},
|
||||
create_new_claim_from_documents: {
|
||||
title: '新建报销草稿',
|
||||
detail: '正在根据当前票据新建报销草稿...'
|
||||
}
|
||||
}
|
||||
const config = configs[reviewAction] || {
|
||||
title: '报销草稿处理',
|
||||
detail: attachmentCount
|
||||
? '正在根据 OCR 结果更新草稿和右侧核对信息...'
|
||||
: '正在更新草稿和右侧核对信息...'
|
||||
}
|
||||
|
||||
startFlowStep('expense-claim-draft', {
|
||||
title: config.title,
|
||||
tool: 'database.expense_claims.save_or_submit',
|
||||
detail: config.detail
|
||||
})
|
||||
}
|
||||
|
||||
function resolveToolCallFlowMeta(toolCall, index) {
|
||||
const toolType = String(toolCall?.tool_type || '').toLowerCase()
|
||||
const toolName = String(toolCall?.tool_name || '').toLowerCase()
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
const responseMessage = String(response.message || '').trim()
|
||||
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
|
||||
if (toolType.includes('rule')) {
|
||||
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
|
||||
@@ -3660,7 +3773,15 @@ export default {
|
||||
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
|
||||
}
|
||||
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
|
||||
return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
if (
|
||||
response.submission_blocked ||
|
||||
String(response.status || '').trim() === 'submitted' ||
|
||||
responseMessage.includes('AI预审') ||
|
||||
responseMessage.includes('审批')
|
||||
) {
|
||||
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
||||
}
|
||||
return { key: 'expense-claim-draft', title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||
}
|
||||
if (toolType.includes('database')) {
|
||||
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
|
||||
@@ -3675,6 +3796,12 @@ export default {
|
||||
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
|
||||
? toolCall.response_json
|
||||
: {}
|
||||
if (String(response.status || '').trim() === 'submitted') {
|
||||
return `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
|
||||
}
|
||||
if (response.submission_blocked) {
|
||||
return String(response.message || '').trim() || 'AI预审发现待补充项,暂未提交审批'
|
||||
}
|
||||
return (
|
||||
String(response.message || response.summary || response.result_summary || '').trim()
|
||||
|| String(toolCall?.tool_name || '').trim()
|
||||
@@ -3687,22 +3814,17 @@ export default {
|
||||
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
|
||||
clearFlowSimulationTimers()
|
||||
const semanticDurations = resolveSemanticPhaseDurations(run)
|
||||
const intentStep = flowSteps.value.find((step) => step.key === 'intent')
|
||||
const extractionStep = flowSteps.value.find((step) => step.key === 'extraction')
|
||||
completePendingFlowStep(
|
||||
'intent',
|
||||
summarizeSemanticIntentDetail(run.semantic_parse),
|
||||
semanticDurations.intentMs
|
||||
intentStep?.startedAt ? null : semanticDurations.intentMs
|
||||
)
|
||||
completePendingFlowStep(
|
||||
'extraction',
|
||||
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
|
||||
semanticDurations.extractionMs
|
||||
)
|
||||
}
|
||||
|
||||
if (flowSteps.value.some((step) => step.key === 'agent')) {
|
||||
completePendingFlowStep(
|
||||
'agent',
|
||||
toolCalls.length ? `已完成 ${toolCalls.length} 个工具调用` : FLOW_STEP_FALLBACKS.agent.completedText
|
||||
extractionStep?.startedAt ? null : semanticDurations.extractionMs
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3734,12 +3856,10 @@ export default {
|
||||
return
|
||||
}
|
||||
flowSteps.value
|
||||
.filter((step) => step.key !== 'result' && ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
||||
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
|
||||
.forEach((step) => {
|
||||
completeFlowStep(step.key, resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED }))
|
||||
})
|
||||
startFlowStep('result', '正在返回处理结果...')
|
||||
completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run))
|
||||
flowFinishedAt.value = Date.now()
|
||||
if (reviewDrawerMode.value === REVIEW_DRAWER_MODE_FLOW) {
|
||||
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
|
||||
@@ -4419,7 +4539,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveInlineReviewChanges() {
|
||||
function saveInlineReviewChanges() {
|
||||
if (!activeReviewPayload.value || !reviewHasUnsavedChanges.value || reviewActionBusy.value) return
|
||||
|
||||
if (reviewInlineEditorKey.value && !commitInlineReviewEditor()) {
|
||||
@@ -4429,28 +4549,36 @@ export default {
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const fields = mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
||||
const documentCorrectionMessage = buildReviewDocumentCorrectionMessage(
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, reviewInlineForm.value)
|
||||
const messageText = `${buildLocalReviewSavedMessage(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value,
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
)
|
||||
await submitComposer({
|
||||
rawText: [buildReviewCorrectionMessage(fields), documentCorrectionMessage].filter(Boolean).join('\n'),
|
||||
userText: buildReviewSubmitUserText(
|
||||
reviewInlineBaseForm.value,
|
||||
reviewInlineForm.value,
|
||||
reviewInlinePendingFiles.value,
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
),
|
||||
pendingText: '正在保存修改并刷新右侧核对信息...',
|
||||
files: reviewInlinePendingFiles.value,
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: 'edit_review',
|
||||
review_form_values: buildReviewFormValues(fields),
|
||||
review_document_form_values: buildReviewDocumentCorrectionContext(reviewDocumentDrafts.value)
|
||||
)} ${buildLocalReviewCompletionMessage(nextReviewPayload)}`
|
||||
|
||||
reviewInlineBaseFields.value = cloneReviewEditFields(fields)
|
||||
reviewInlineBaseForm.value = { ...reviewInlineForm.value }
|
||||
reviewDocumentBaseDrafts.value = cloneReviewDocumentDrafts(reviewDocumentDrafts.value)
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
messages.value.push(createMessage('assistant', messageText, [], {
|
||||
meta: ['本地修改'],
|
||||
draftPayload: latestReviewMessage.value?.draftPayload || null,
|
||||
reviewPayload: nextReviewPayload
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
@@ -4517,6 +4645,7 @@ export default {
|
||||
const extraContext = options.extraContext && typeof options.extraContext === 'object'
|
||||
? { ...options.extraContext }
|
||||
: {}
|
||||
const reviewAction = String(extraContext.review_action || '').trim()
|
||||
const reviewAttachmentNames = extractReviewAttachmentNames(activeReviewPayload.value)
|
||||
const hasExistingDocumentEvent =
|
||||
Boolean(String(draftClaimId.value || '').trim()) || reviewAttachmentNames.length > 0
|
||||
@@ -4527,14 +4656,14 @@ export default {
|
||||
hasExistingDocumentEvent &&
|
||||
!resolvedUploadDisposition &&
|
||||
!options.skipUploadDecisionPrompt &&
|
||||
!String(extraContext.review_action || '').trim()
|
||||
!reviewAction
|
||||
) {
|
||||
uploadDecisionDialogOpen.value = true
|
||||
return null
|
||||
}
|
||||
|
||||
resetFlowRun({ rawText, attachmentCount: files.length })
|
||||
if (rawText) {
|
||||
resetFlowRun()
|
||||
if (rawText && !reviewAction) {
|
||||
startFlowStep('intent', '正在识别业务意图...')
|
||||
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
|
||||
}
|
||||
@@ -4589,17 +4718,18 @@ export default {
|
||||
let ocrFilePreviews = []
|
||||
|
||||
if (files.length) {
|
||||
startFlowStep('ocr', `正在识别 ${files.length} 份附件...`)
|
||||
const ocrStartedAt = Date.now()
|
||||
startFlowStep('ocr', { detail: `正在识别 ${files.length} 份附件...`, startedAt: ocrStartedAt })
|
||||
try {
|
||||
ocrPayload = await recognizeOcrFiles(files)
|
||||
ocrSummary = buildOcrSummary(ocrPayload)
|
||||
ocrDocuments = normalizeOcrDocuments(ocrPayload)
|
||||
ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
|
||||
rememberFilePreviews(ocrFilePreviews)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`)
|
||||
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`, Date.now() - ocrStartedAt)
|
||||
} catch (error) {
|
||||
console.warn('OCR request failed:', error)
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称')
|
||||
completeFlowStep('ocr', 'OCR识别失败,已继续使用附件名称', Date.now() - ocrStartedAt)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4619,16 +4749,9 @@ export default {
|
||||
extraContext.review_action = 'create_new_claim_from_documents'
|
||||
}
|
||||
|
||||
const runningExtractionStep = flowSteps.value.find(
|
||||
(step) => step.key === 'extraction' && step.status === FLOW_STEP_STATUS_RUNNING
|
||||
)
|
||||
if (runningExtractionStep) {
|
||||
completeFlowStep(
|
||||
'extraction',
|
||||
runningExtractionStep.detail || FLOW_STEP_FALLBACKS.extraction.completedText
|
||||
)
|
||||
}
|
||||
startFlowStep('agent', FLOW_STEP_FALLBACKS.agent.runningText)
|
||||
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
|
||||
attachmentCount: effectiveFileNames.length
|
||||
})
|
||||
|
||||
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
|
||||
const payload = await runOrchestrator(
|
||||
@@ -4642,6 +4765,8 @@ export default {
|
||||
is_admin: Boolean(user.isAdmin),
|
||||
name: user.name || '',
|
||||
role: user.role || '',
|
||||
department: user.department || user.departmentName || '',
|
||||
department_name: user.department || user.departmentName || '',
|
||||
position: user.position || '',
|
||||
grade: user.grade || '',
|
||||
...buildClientTimeContext(),
|
||||
@@ -4749,7 +4874,10 @@ export default {
|
||||
}
|
||||
|
||||
function openEditReviewDialog(message) {
|
||||
reviewEditFields.value = cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
const sourceFields = reviewInlineBaseFields.value.length
|
||||
? mergeInlineReviewFields(reviewInlineBaseFields.value, reviewInlineForm.value)
|
||||
: cloneReviewEditFields(message?.reviewPayload?.edit_fields)
|
||||
reviewEditFields.value = cloneReviewEditFields(sourceFields)
|
||||
reviewActionMessageId.value = String(message?.id || '')
|
||||
reviewEditDialogOpen.value = true
|
||||
}
|
||||
@@ -4761,22 +4889,46 @@ export default {
|
||||
reviewActionMessageId.value = ''
|
||||
}
|
||||
|
||||
async function applyEditedReview() {
|
||||
function applyEditedReview() {
|
||||
if (reviewActionBusy.value) return
|
||||
|
||||
reviewActionBusy.value = true
|
||||
try {
|
||||
const fields = cloneReviewEditFields(reviewEditFields.value)
|
||||
await submitComposer({
|
||||
rawText: buildReviewCorrectionMessage(fields),
|
||||
userText: '我已修改识别信息,请按最新内容更新。',
|
||||
pendingText: '正在根据修改内容重新识别...',
|
||||
systemGenerated: true,
|
||||
extraContext: {
|
||||
review_action: 'edit_review',
|
||||
review_form_values: buildReviewFormValues(fields)
|
||||
}
|
||||
const nextInlineState = buildInlineReviewState({
|
||||
...(activeReviewPayload.value || {}),
|
||||
edit_fields: fields
|
||||
})
|
||||
const nextReviewPayload = buildLocallySyncedReviewPayload(activeReviewPayload.value, nextInlineState)
|
||||
const messageText = `${buildLocalReviewSavedMessage(
|
||||
reviewInlineForm.value,
|
||||
nextInlineState,
|
||||
[],
|
||||
reviewDocumentBaseDrafts.value,
|
||||
reviewDocumentDrafts.value
|
||||
)} ${buildLocalReviewCompletionMessage(nextReviewPayload)}`
|
||||
|
||||
reviewInlineForm.value = { ...nextInlineState }
|
||||
reviewInlineBaseForm.value = { ...nextInlineState }
|
||||
reviewInlineBaseFields.value = cloneReviewEditFields(fields)
|
||||
if (latestReviewMessage.value) {
|
||||
latestReviewMessage.value.reviewPayload = nextReviewPayload
|
||||
}
|
||||
if (currentInsight.value?.agent) {
|
||||
currentInsight.value = {
|
||||
...currentInsight.value,
|
||||
agent: {
|
||||
...currentInsight.value.agent,
|
||||
reviewPayload: nextReviewPayload
|
||||
}
|
||||
}
|
||||
}
|
||||
messages.value.push(createMessage('assistant', messageText, [], {
|
||||
meta: ['本地修改'],
|
||||
draftPayload: latestReviewMessage.value?.draftPayload || null,
|
||||
reviewPayload: nextReviewPayload
|
||||
}))
|
||||
nextTick(scrollToBottom)
|
||||
} finally {
|
||||
reviewActionBusy.value = false
|
||||
}
|
||||
|
||||
@@ -44,9 +44,6 @@ const DOCUMENT_TYPE_LABELS = {
|
||||
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'hotel',
|
||||
'transport',
|
||||
'meal',
|
||||
'meeting',
|
||||
'entertainment'
|
||||
])
|
||||
@@ -100,7 +97,7 @@ function buildFallbackProgressSteps() {
|
||||
return [
|
||||
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
|
||||
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
|
||||
{ index: 3, label: 'AI验审', time: '待处理' },
|
||||
{ index: 3, label: 'AI预审', time: '待处理' },
|
||||
{ index: 4, label: '直属领导审批', time: '待处理' },
|
||||
{ index: 5, label: '财务审批', time: '待处理' },
|
||||
{ index: 6, label: '归档入账', time: '待处理' }
|
||||
@@ -281,9 +278,6 @@ function buildDraftBlockingIssues(request, expenseItems) {
|
||||
if (isPlaceholderValue(request.profileName)) {
|
||||
issues.push('申请人未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.profileDepartment)) {
|
||||
issues.push('所属部门未完善')
|
||||
}
|
||||
if (isPlaceholderValue(request.typeLabel)) {
|
||||
issues.push('报销类型未完善')
|
||||
}
|
||||
@@ -1097,9 +1091,9 @@ export default {
|
||||
const claimStatus = String(payload?.status || '').trim().toLowerCase()
|
||||
const approvalStage = String(payload?.approval_stage || payload?.approvalStage || '').trim()
|
||||
if (claimStatus === 'submitted') {
|
||||
toast(`${request.value.id} 已完成 AI验审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||
toast(`${request.value.id} 已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}。`)
|
||||
} else if (claimStatus === 'supplement') {
|
||||
toast(`${request.value.id} AI验审未通过,已转待补充。`)
|
||||
toast(`${request.value.id} AI预审未通过,已转待补充。`)
|
||||
} else {
|
||||
toast(`${request.value.id} 提交结果已更新。`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user