feat: 报销审批流重构与管家计划全链路贯通
- 重构报销状态注册表、审批流路由与平台风险标记 - 完善管家意图规划器与模型计划构建器全链路 - 新增 OCR Worker 脚本、数据库会话管理与通知状态 - 优化文档中心、日志视图、预算中心与员工管理交互 - 增强工作台摘要、图标资源与全局主题样式 - 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
@@ -13,7 +13,10 @@ import {
|
||||
buildLocalApplicationPreview,
|
||||
buildLocalApplicationPreviewMessage,
|
||||
buildModelRefinedApplicationPreview,
|
||||
applicationDateRangesOverlap,
|
||||
normalizeApplicationPreview,
|
||||
normalizeTransportModeOption,
|
||||
resolveApplicationDateRange,
|
||||
shouldUseLocalApplicationPreview
|
||||
} from '../../utils/expenseApplicationPreview.js'
|
||||
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
|
||||
@@ -21,16 +24,275 @@ import { fetchOntologyParse } from '../../services/ontology.js'
|
||||
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
|
||||
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
|
||||
import { fetchReceiptFolderItems } from '../../services/receiptFolder.js'
|
||||
import { fetchStewardSlotDecision } from '../../services/steward.js'
|
||||
import {
|
||||
handleBudgetCompileReportSubmit,
|
||||
shouldUseBudgetCompileReport
|
||||
} from './budgetAssistantReportModel.js'
|
||||
|
||||
const STEWARD_ASSISTANT_NAME = '小财管家'
|
||||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 18
|
||||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 14
|
||||
const STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS = 10
|
||||
const STEWARD_DELEGATED_THINKING_INTERVAL_MS = 8
|
||||
const STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE = 4
|
||||
const STEWARD_DELEGATED_THINKING_CHUNK_SIZE = 5
|
||||
const APPLICATION_PREVIEW_FIELD_ACTION_SET = 'set_application_preview_field'
|
||||
|
||||
const APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP = {
|
||||
applicationType: 'expense_type',
|
||||
time: 'time_range',
|
||||
location: 'location',
|
||||
reason: 'reason',
|
||||
amount: 'amount',
|
||||
transportMode: 'transport_mode',
|
||||
department: 'department_name',
|
||||
applicant: 'employee_name',
|
||||
grade: 'employee_grade'
|
||||
}
|
||||
|
||||
const APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP = {
|
||||
费用类型: 'expense_type',
|
||||
申请类型: 'expense_type',
|
||||
发生时间: 'time_range',
|
||||
出发时间: 'time_range',
|
||||
申请时间: 'time_range',
|
||||
地点: 'location',
|
||||
事由: 'reason',
|
||||
金额: 'amount',
|
||||
系统预估费用: 'amount',
|
||||
出行方式: 'transport_mode',
|
||||
附件: 'attachments',
|
||||
'附件/凭证': 'attachments',
|
||||
商户: 'merchant_name',
|
||||
'商户/开票方': 'merchant_name',
|
||||
客户: 'customer_name',
|
||||
客户或项目对象: 'customer_name'
|
||||
}
|
||||
|
||||
const ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP = {
|
||||
expense_type: 'applicationType',
|
||||
time_range: 'time',
|
||||
location: 'location',
|
||||
reason: 'reason',
|
||||
amount: 'amount',
|
||||
transport_mode: 'transportMode',
|
||||
department_name: 'department',
|
||||
employee_name: 'applicant',
|
||||
employee_grade: 'grade'
|
||||
}
|
||||
|
||||
const ONTOLOGY_FIELD_DISPLAY_LABEL_MAP = {
|
||||
expense_type: '费用类型',
|
||||
time_range: '时间',
|
||||
location: '地点',
|
||||
reason: '事由',
|
||||
amount: '金额',
|
||||
transport_mode: '出行方式',
|
||||
attachments: '附件/凭证',
|
||||
customer_name: '客户或项目对象',
|
||||
merchant_name: '商户/开票方',
|
||||
department_name: '所属部门',
|
||||
employee_name: '申请人',
|
||||
employee_grade: '职级'
|
||||
}
|
||||
|
||||
const APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS = new Set([
|
||||
'amount',
|
||||
'attachments',
|
||||
'employee_no',
|
||||
'department_name',
|
||||
'employee_name'
|
||||
])
|
||||
|
||||
const APPLICATION_DUPLICATE_IGNORED_STATUSES = new Set([
|
||||
'cancelled',
|
||||
'canceled',
|
||||
'void',
|
||||
'voided',
|
||||
'deleted',
|
||||
'已取消',
|
||||
'已作废',
|
||||
'作废',
|
||||
'已删除'
|
||||
])
|
||||
|
||||
function normalizeClaimListPayload(payload) {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
return Array.isArray(payload?.items) ? payload.items : []
|
||||
}
|
||||
|
||||
function normalizeClaimRiskFlags(claim) {
|
||||
const flags = claim?.risk_flags_json || claim?.riskFlagsJson || claim?.riskFlags || []
|
||||
if (Array.isArray(flags)) {
|
||||
return flags
|
||||
}
|
||||
return flags && typeof flags === 'object' ? [flags] : []
|
||||
}
|
||||
|
||||
function extractApplicationDetailFromClaim(claim) {
|
||||
return normalizeClaimRiskFlags(claim).reduce((found, item) => {
|
||||
if (found || !item || typeof item !== 'object') {
|
||||
return found
|
||||
}
|
||||
const detail = item.application_detail || item.applicationDetail
|
||||
return detail && typeof detail === 'object' ? detail : null
|
||||
}, null)
|
||||
}
|
||||
|
||||
function isApplicationClaimRecord(claim) {
|
||||
const expenseType = String(claim?.expense_type || claim?.expenseType || '').trim().toLowerCase()
|
||||
const claimNo = String(claim?.claim_no || claim?.claimNo || '').trim().toUpperCase()
|
||||
return (
|
||||
expenseType === 'application' ||
|
||||
expenseType === 'expense_application' ||
|
||||
expenseType.endsWith('_application') ||
|
||||
claimNo.startsWith('AP-') ||
|
||||
claimNo.startsWith('APP-') ||
|
||||
Boolean(extractApplicationDetailFromClaim(claim))
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeApplicationExpenseType(value) {
|
||||
const text = String(value || '').trim().toLowerCase()
|
||||
if (!text) {
|
||||
return ''
|
||||
}
|
||||
if (text === 'travel_application' || /差旅|出差/.test(text)) {
|
||||
return 'travel_application'
|
||||
}
|
||||
if (text === 'purchase_application' || /采购/.test(text)) {
|
||||
return 'purchase_application'
|
||||
}
|
||||
if (text === 'meeting_application' || /会务|会议/.test(text)) {
|
||||
return 'meeting_application'
|
||||
}
|
||||
if (text === 'expense_application' || text === 'application' || text.endsWith('_application')) {
|
||||
return text === 'application' ? 'expense_application' : text
|
||||
}
|
||||
return 'expense_application'
|
||||
}
|
||||
|
||||
function resolveClaimApplicationExpenseType(claim) {
|
||||
const detail = extractApplicationDetailFromClaim(claim) || {}
|
||||
return normalizeApplicationExpenseType(
|
||||
claim?.expense_type ||
|
||||
claim?.expenseType ||
|
||||
detail.application_type ||
|
||||
detail.applicationType ||
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
function isIgnoredApplicationDuplicateStatus(status) {
|
||||
return APPLICATION_DUPLICATE_IGNORED_STATUSES.has(String(status || '').trim().toLowerCase())
|
||||
}
|
||||
|
||||
function resolveClaimApplicationDateRange(claim) {
|
||||
const detail = extractApplicationDetailFromClaim(claim) || {}
|
||||
return (
|
||||
resolveApplicationDateRange(
|
||||
detail.time ||
|
||||
detail.time_range ||
|
||||
detail.timeRange ||
|
||||
detail.application_time ||
|
||||
detail.applicationTime ||
|
||||
detail.application_business_time ||
|
||||
detail.applicationBusinessTime ||
|
||||
detail.application_date ||
|
||||
detail.applicationDate,
|
||||
detail.days || detail.application_days || detail.applicationDays
|
||||
) ||
|
||||
resolveApplicationDateRange(claim?.occurred_at || claim?.occurredAt || '')
|
||||
)
|
||||
}
|
||||
|
||||
function formatApplicationDateRangeLabel(range) {
|
||||
if (!range?.startDate) {
|
||||
return '待确认'
|
||||
}
|
||||
return range.startDate === range.endDate ? range.startDate : `${range.startDate} 至 ${range.endDate}`
|
||||
}
|
||||
|
||||
function findOverlappingApplicationClaim(applicationPreview, claimsPayload) {
|
||||
const preview = normalizeApplicationPreview(applicationPreview)
|
||||
const fields = preview.fields || {}
|
||||
const currentRange = resolveApplicationDateRange(fields.time, fields.days)
|
||||
if (!currentRange) {
|
||||
return null
|
||||
}
|
||||
const currentExpenseType = normalizeApplicationExpenseType(fields.applicationType)
|
||||
const claims = normalizeClaimListPayload(claimsPayload)
|
||||
for (const claim of claims) {
|
||||
if (!isApplicationClaimRecord(claim) || isIgnoredApplicationDuplicateStatus(claim?.status)) {
|
||||
continue
|
||||
}
|
||||
const existingExpenseType = resolveClaimApplicationExpenseType(claim)
|
||||
if (currentExpenseType && existingExpenseType && currentExpenseType !== existingExpenseType) {
|
||||
continue
|
||||
}
|
||||
const existingRange = resolveClaimApplicationDateRange(claim)
|
||||
if (!existingRange || !applicationDateRangesOverlap(currentRange, existingRange)) {
|
||||
continue
|
||||
}
|
||||
return {
|
||||
claim,
|
||||
currentRange,
|
||||
existingRange,
|
||||
claimId: String(claim?.id || claim?.claim_id || claim?.claimId || '').trim(),
|
||||
claimNo: String(claim?.claim_no || claim?.claimNo || claim?.id || '').trim(),
|
||||
status: String(claim?.approval_stage || claim?.approvalStage || claim?.status || '').trim(),
|
||||
reason: String(claim?.reason || '').trim(),
|
||||
location: String(claim?.location || '').trim()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildApplicationDateConflictMessage(conflict) {
|
||||
const claimNo = conflict?.claimNo || '已有申请'
|
||||
return [
|
||||
'我先检查了你的申请时间,发现同一天或重叠日期已经存在差旅申请,不能重复创建。',
|
||||
'',
|
||||
'已有申请:',
|
||||
`- **单号**:${claimNo}`,
|
||||
`- **申请时间**:${formatApplicationDateRangeLabel(conflict?.existingRange)}`,
|
||||
conflict?.location ? `- **地点**:${conflict.location}` : '',
|
||||
conflict?.reason ? `- **事由**:${conflict.reason}` : '',
|
||||
`- **当前节点**:${conflict?.status || '处理中'}`,
|
||||
'',
|
||||
`本次识别时间:${formatApplicationDateRangeLabel(conflict?.currentRange)}`,
|
||||
'',
|
||||
'请先查看已有申请,或修改本次出差时间后再继续。'
|
||||
].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function buildApplicationDateConflictActions(conflict) {
|
||||
const actions = []
|
||||
if (conflict?.claimId) {
|
||||
actions.push({
|
||||
action_type: 'open_application_detail',
|
||||
label: '查看已有申请',
|
||||
description: conflict.claimNo ? `进入 ${conflict.claimNo} 单据详情。` : '进入已有申请单据详情。',
|
||||
icon: 'mdi mdi-file-search-outline',
|
||||
payload: {
|
||||
claim_id: conflict.claimId,
|
||||
claim_no: conflict.claimNo
|
||||
}
|
||||
})
|
||||
}
|
||||
actions.push({
|
||||
action_type: 'prefill_composer',
|
||||
label: '修改出差时间',
|
||||
description: '在输入框中补充新的出差日期后继续。',
|
||||
icon: 'mdi mdi-calendar-edit-outline',
|
||||
payload: {
|
||||
prompt_prefill: '修改出差时间为:'
|
||||
}
|
||||
})
|
||||
return actions
|
||||
}
|
||||
|
||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const {
|
||||
MAX_ATTACHMENTS,
|
||||
@@ -145,8 +407,21 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return Array.isArray(normalized.missingFields) ? normalized.missingFields : []
|
||||
}
|
||||
|
||||
function isBlockingApplicationOntologyField(key = '') {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
return Boolean(normalizedKey && !APPLICATION_NON_BLOCKING_ONTOLOGY_FIELDS.has(normalizedKey))
|
||||
}
|
||||
|
||||
function resolveBlockingApplicationMissingFieldsForSteward(preview = {}) {
|
||||
return resolveApplicationPreviewMissingFieldsForSteward(preview).filter((label) => {
|
||||
const ontologyKey = APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || ''
|
||||
return !ontologyKey || isBlockingApplicationOntologyField(ontologyKey)
|
||||
})
|
||||
}
|
||||
|
||||
function buildStewardApplicationPreviewSuggestedActions(preview = {}) {
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(preview)
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
|
||||
if (!missingFields.includes('出行方式')) {
|
||||
return []
|
||||
}
|
||||
@@ -158,77 +433,298 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return APPLICATION_TRANSPORT_MODE_OPTIONS.map((mode) => ({
|
||||
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||||
label: mode,
|
||||
description: `选择${mode}作为本次出行方式,并同步费用测算。`,
|
||||
description: `选择${mode}后,由小财管家继续查询票价并测算费用。`,
|
||||
icon: iconMap[mode] || 'mdi mdi-map-marker-path',
|
||||
payload: {
|
||||
field_key: 'transportMode',
|
||||
field_label: '出行方式',
|
||||
value: mode
|
||||
value: mode,
|
||||
applicationPreview: normalized,
|
||||
steward_delegated_field_completion: true
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveStewardContinuationCurrentTask(continuation = null) {
|
||||
const task = continuation?.currentTask || continuation?.current_task || null
|
||||
return task && typeof task === 'object' ? task : null
|
||||
}
|
||||
|
||||
function normalizeCanonicalFieldList(fields = []) {
|
||||
const normalized = []
|
||||
if (!Array.isArray(fields)) {
|
||||
return normalized
|
||||
}
|
||||
fields.forEach((field) => {
|
||||
const key = String(field || '').trim()
|
||||
if (key && !normalized.includes(key)) {
|
||||
normalized.push(key)
|
||||
}
|
||||
})
|
||||
return normalized
|
||||
}
|
||||
|
||||
function buildStewardSlotDecisionOntologyFields(preview = {}, continuation = null) {
|
||||
const normalizedPreview = normalizeApplicationPreview(preview)
|
||||
const previewFields = normalizedPreview.fields || {}
|
||||
const task = resolveStewardContinuationCurrentTask(continuation)
|
||||
const taskFields = task?.ontology_fields || task?.ontologyFields || {}
|
||||
const fields = {}
|
||||
Object.entries(taskFields || {}).forEach(([key, value]) => {
|
||||
const normalizedKey = String(key || '').trim()
|
||||
const normalizedValue = String(value || '').trim()
|
||||
if (normalizedKey && normalizedValue) {
|
||||
fields[normalizedKey] = normalizedValue
|
||||
}
|
||||
})
|
||||
Object.entries(APPLICATION_PREVIEW_ONTOLOGY_FIELD_MAP).forEach(([previewKey, ontologyKey]) => {
|
||||
const value = String(previewFields[previewKey] || '').trim()
|
||||
if (value && value !== '待补充' && !fields[ontologyKey]) {
|
||||
fields[ontologyKey] = value
|
||||
}
|
||||
})
|
||||
return fields
|
||||
}
|
||||
|
||||
function buildStewardSlotDecisionMissingFields(preview = {}, continuation = null, ontologyFields = {}) {
|
||||
const task = resolveStewardContinuationCurrentTask(continuation)
|
||||
const taskMissingFields = normalizeCanonicalFieldList(task?.missing_fields || task?.missingFields || [])
|
||||
.filter((key) => isBlockingApplicationOntologyField(key) && !String(ontologyFields[key] || '').trim())
|
||||
if (taskMissingFields.length) {
|
||||
return taskMissingFields
|
||||
}
|
||||
return resolveApplicationPreviewMissingFieldsForSteward(preview)
|
||||
.map((label) => APPLICATION_MISSING_LABEL_ONTOLOGY_FIELD_MAP[String(label || '').trim()] || '')
|
||||
.filter((key, index, list) =>
|
||||
key &&
|
||||
isBlockingApplicationOntologyField(key) &&
|
||||
!String(ontologyFields[key] || '').trim() &&
|
||||
list.indexOf(key) === index
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchStewardApplicationSlotDecision(preview = {}, rawText = '', continuation = null) {
|
||||
const ontologyFields = buildStewardSlotDecisionOntologyFields(preview, continuation)
|
||||
const missingFields = buildStewardSlotDecisionMissingFields(preview, continuation, ontologyFields)
|
||||
try {
|
||||
return await fetchStewardSlotDecision({
|
||||
task_type: 'expense_application',
|
||||
user_message: String(rawText || '').trim(),
|
||||
ontology_fields: ontologyFields,
|
||||
missing_fields: missingFields,
|
||||
task_context: {
|
||||
steward_continuation: continuation || null,
|
||||
application_preview: normalizeApplicationPreview(preview)
|
||||
}
|
||||
}, {
|
||||
timeoutMs: 45000,
|
||||
timeoutMessage: '小财管家字段决策超时,已按当前本体缺口继续追问。'
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Steward slot decision failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatStewardDecisionUserText(text = '') {
|
||||
let formatted = String(text || '').trim()
|
||||
Object.entries(ONTOLOGY_FIELD_DISPLAY_LABEL_MAP).forEach(([fieldKey, label]) => {
|
||||
const escapedKey = fieldKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
formatted = formatted
|
||||
.replace(new RegExp(`(\\s*${escapedKey}\\s*)`, 'g'), '')
|
||||
.replace(new RegExp(`\\(\\s*${escapedKey}\\s*\\)`, 'g'), '')
|
||||
.replace(new RegExp(`\\b${escapedKey}\\b`, 'g'), label)
|
||||
})
|
||||
return formatted.replace(/\s{2,}/g, ' ').trim()
|
||||
}
|
||||
|
||||
function buildStewardSlotDecisionMessage(decision = null, preview = {}, fallbackText = '') {
|
||||
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
|
||||
return fallbackText
|
||||
}
|
||||
const question = formatStewardDecisionUserText(decision.question || '')
|
||||
const rationale = formatStewardDecisionUserText(decision.rationale || '')
|
||||
const parts = [
|
||||
'我已经识别出这一步要先处理申请单,但当前还不能直接生成可提交的申请核对表。',
|
||||
'',
|
||||
rationale ? `**原因是:${rationale}**` : '',
|
||||
'',
|
||||
question || buildStewardApplicationPreviewMessage(preview, fallbackText)
|
||||
].filter((item) => item !== '')
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function buildStewardSlotDecisionSuggestedActions(decision = null, preview = {}) {
|
||||
if (!decision || String(decision.next_action || '').trim() !== 'ask_user') {
|
||||
return []
|
||||
}
|
||||
const normalizedPreview = normalizeApplicationPreview(preview)
|
||||
const iconMap = {
|
||||
火车: 'mdi mdi-train',
|
||||
飞机: 'mdi mdi-airplane',
|
||||
轮船: 'mdi mdi-ferry'
|
||||
}
|
||||
const actions = Array.isArray(decision.options) ? decision.options : []
|
||||
return actions.map((option) => {
|
||||
const canonicalField = String(option?.field_key || option?.fieldKey || '').trim()
|
||||
if (canonicalField && !isBlockingApplicationOntologyField(canonicalField)) {
|
||||
return null
|
||||
}
|
||||
const fieldKey = ONTOLOGY_FIELD_APPLICATION_PREVIEW_KEY_MAP[canonicalField] || canonicalField
|
||||
const value = String(option?.value || option?.label || '').trim()
|
||||
const label = String(option?.label || value).trim()
|
||||
const normalizedValue = fieldKey === 'transportMode'
|
||||
? normalizeTransportModeOption(value || label, '')
|
||||
: value
|
||||
if (!fieldKey || !value || !label) {
|
||||
return null
|
||||
}
|
||||
if (fieldKey === 'transportMode' && !normalizedValue) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
action_type: APPLICATION_PREVIEW_FIELD_ACTION_SET,
|
||||
label,
|
||||
description: String(option?.description || '').trim() || `选择${label}后,由小财管家继续测算并生成核对结果。`,
|
||||
icon: iconMap[label] || iconMap[value] || 'mdi mdi-form-select',
|
||||
payload: {
|
||||
field_key: fieldKey,
|
||||
field_label: ONTOLOGY_FIELD_DISPLAY_LABEL_MAP[canonicalField] || label,
|
||||
value: normalizedValue,
|
||||
applicationPreview: normalizedPreview,
|
||||
steward_delegated_field_completion: true
|
||||
}
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
function buildStewardApplicationPreviewMessage(preview = {}, fallbackText = '') {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
const missingFields = resolveApplicationPreviewMissingFieldsForSteward(normalized)
|
||||
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(normalized)
|
||||
if (!missingFields.length) {
|
||||
return fallbackText
|
||||
}
|
||||
|
||||
if (missingFields.includes('出行方式')) {
|
||||
return [
|
||||
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
|
||||
'我已经识别出这一步要先处理出差申请,但现在还不能生成可提交的申请核对表。',
|
||||
'',
|
||||
'**原因是:还缺少“出行方式”。**',
|
||||
'',
|
||||
`本次申请是前往${fields.location || '目的地'}的差旅事项,出行方式会影响交通费用口径和系统预估金额。`,
|
||||
'',
|
||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择更新下方核对表和费用测算,再继续判断是否可以提交申请。'
|
||||
'请先告诉我你打算怎么出行:**火车、飞机或轮船**。我会根据你的选择生成申请核对表并同步费用测算,再继续判断是否可以提交申请。'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
return [
|
||||
'我已经生成这一步的申请单核对结果,但现在还不能继续提交。',
|
||||
'我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表。',
|
||||
'',
|
||||
`**还需要你补充:${missingFields.join('、')}。**`,
|
||||
'',
|
||||
`请先补充 **${missingFields[0]}**。你也可以直接点击下方核对表里的对应行编辑;补齐后我再继续推进下一步。`
|
||||
`请先补充 **${missingFields[0]}**。补齐后我再生成申请核对表并继续推进下一步。`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function shouldPauseStewardApplicationPreview(preview = {}) {
|
||||
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
|
||||
}
|
||||
|
||||
function extractStewardCarryLine(text = '', label = '') {
|
||||
const escapedLabel = String(label || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = String(text || '').match(new RegExp(`(?:^|\\n)${escapedLabel}[::]([^\\n]+)`, 'u'))
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractStewardDelegatedTaskTitle(text = '', sessionType = '') {
|
||||
const taskMatch = String(text || '').match(/请(?:先)?(?:创建申请单|填写报销单|继续填写报销单)[::]([^。\n]+)/u)
|
||||
if (taskMatch?.[1]) {
|
||||
return taskMatch[1].trim()
|
||||
}
|
||||
return String(sessionType || '').trim() === 'application' ? '本次出差申请' : '本次费用报销'
|
||||
}
|
||||
|
||||
function sanitizeStewardDelegatedTaskSummary(summary = '', sessionType = '') {
|
||||
const text = String(summary || '').trim()
|
||||
if (String(sessionType || '').trim() !== 'application') {
|
||||
return text
|
||||
}
|
||||
return text
|
||||
.replace(/交通方式和(?:预算|预计)?金额待补充/g, '交通方式待补充')
|
||||
.replace(/出行方式和(?:预算|预计)?金额待补充/g, '出行方式待补充')
|
||||
.replace(/交通方式及(?:预估|预计|预算)?费用/g, '交通方式')
|
||||
.replace(/出行方式及(?:预估|预计|预算)?费用/g, '出行方式')
|
||||
.replace(/(?:费用预算|预算费用|出差费用预算)(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)?[,,、;;\s]*/g, '')
|
||||
.replace(/[,,、;;\s]*(?:预估|预计|预算)费用(?:待补充|待确认|待填写|需补充|需要补充|需确认|需要确认|未补充|缺失)?/g, '')
|
||||
.replace(/[,,、;;\s]*(?:预算|预计)?金额(?:待补充|待确认|待填写|需补充|需要补充|未补充|缺失)/g, '')
|
||||
.replace(/([,,、;;。])\1+/g, '$1')
|
||||
.replace(/[,,、;;\s]+。/g, '。')
|
||||
.replace(/[,,、;;\s]+$/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function summarizeApplicationPreviewForSteward(preview = {}) {
|
||||
const normalized = normalizeApplicationPreview(preview)
|
||||
const fields = normalized.fields || {}
|
||||
return [
|
||||
fields.time ? `时间:${fields.time}` : '',
|
||||
fields.location ? `地点:${fields.location}` : '',
|
||||
fields.reason ? `事由:${fields.reason}` : '',
|
||||
fields.applicationType ? `类型:${fields.applicationType}` : ''
|
||||
].filter(Boolean).join(';')
|
||||
}
|
||||
|
||||
function buildStewardDelegatedThinkingEvents(sessionType = '', continuation = null, context = {}) {
|
||||
const actionLabel = resolveStewardDelegatedActionLabel(sessionType)
|
||||
const eventPrefix = `steward-delegated-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
const rawText = String(context.rawText || '').trim()
|
||||
const taskTitle = extractStewardDelegatedTaskTitle(rawText, sessionType)
|
||||
const taskSummary = sanitizeStewardDelegatedTaskSummary(
|
||||
extractStewardCarryLine(rawText, '任务摘要'),
|
||||
sessionType
|
||||
)
|
||||
const identifiedInfo = summarizeApplicationPreviewForSteward(context.applicationPreview)
|
||||
|| extractStewardCarryLine(rawText, '已识别信息')
|
||||
const carryMissingInfo = extractStewardCarryLine(rawText, '还需要补充')
|
||||
const applicationMissingFields = context.applicationPreview
|
||||
? resolveBlockingApplicationMissingFieldsForSteward(context.applicationPreview)
|
||||
: []
|
||||
const missingInfo = applicationMissingFields.length
|
||||
? applicationMissingFields.join('、')
|
||||
: carryMissingInfo
|
||||
const events = [
|
||||
{
|
||||
eventId: `${eventPrefix}-confirm`,
|
||||
title: '接收确认',
|
||||
content: '已收到你的确认,小财管家继续推进当前任务。'
|
||||
eventId: `${eventPrefix}-intent`,
|
||||
title: '理解当前任务',
|
||||
content: taskSummary
|
||||
? `你确认先处理“${taskTitle}”。我把这一步理解为:${taskSummary}。`
|
||||
: `你确认先处理“${taskTitle}”,我会先生成${actionLabel}结果。`
|
||||
},
|
||||
{
|
||||
eventId: `${eventPrefix}-coordinate`,
|
||||
title: '协调能力',
|
||||
content: `正在协调${actionLabel}能力,先生成可核对的结果;这个过程仍由小财管家统一呈现。`
|
||||
eventId: `${eventPrefix}-known`,
|
||||
title: '核对已知信息',
|
||||
content: identifiedInfo
|
||||
? `当前已识别到:${identifiedInfo}。`
|
||||
: `当前先围绕“${taskTitle}”生成可核对内容,具体缺口会在核对结果里继续判断。`
|
||||
}
|
||||
]
|
||||
const applicationMissingFields = context.applicationPreview
|
||||
? resolveApplicationPreviewMissingFieldsForSteward(context.applicationPreview)
|
||||
: []
|
||||
if (applicationMissingFields.length) {
|
||||
if (missingInfo) {
|
||||
const transportMissing = /出行方式/.test(missingInfo)
|
||||
events.push({
|
||||
eventId: `${eventPrefix}-gap`,
|
||||
title: '识别缺口',
|
||||
content: `核对结果还缺少${applicationMissingFields.join('、')},我会先向你追问,不直接推进提交。`
|
||||
title: '判断待补充信息',
|
||||
content: transportMissing
|
||||
? '这一步还没有说明出行方式。出行方式会影响交通费用测算,所以我会先问你选择火车、飞机或轮船,不会直接推进提交。'
|
||||
: `这一步还缺少${missingInfo},我会先向你确认这些信息,不直接推进提交。`
|
||||
})
|
||||
} else {
|
||||
events.push({
|
||||
eventId: `${eventPrefix}-ready`,
|
||||
title: '判断下一步动作',
|
||||
content: `这一步的关键业务信息已形成核对结果。我会先让你检查${actionLabel},确认后再继续入库、生成草稿或处理后续任务。`
|
||||
})
|
||||
}
|
||||
events.push(
|
||||
{
|
||||
eventId: `${eventPrefix}-output`,
|
||||
title: '准备输出',
|
||||
content: '结果准备好后,我会先逐字输出正文,再展示核对表、卡片或确认按钮。'
|
||||
}
|
||||
)
|
||||
return events
|
||||
}
|
||||
|
||||
@@ -257,6 +753,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
|
||||
async function typeStewardDelegatedMessage(messageId, finalText, finalExtras = {}, context = {}) {
|
||||
const continuation = finalExtras.stewardContinuation || context.stewardContinuation || null
|
||||
const pendingSuggestedActions = Array.isArray(finalExtras.suggestedActions)
|
||||
? finalExtras.suggestedActions
|
||||
: []
|
||||
const message = messages.value.find((item) => item.id === messageId)
|
||||
if (!message) {
|
||||
return
|
||||
@@ -287,11 +786,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
nextTick(scrollToBottom)
|
||||
|
||||
const chars = Array.from(String(eventData.content || ''))
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_THINKING_INTERVAL_MS)
|
||||
event.content = chars.slice(0, index + 1).join('')
|
||||
index = Math.min(chars.length, index + STEWARD_DELEGATED_THINKING_CHUNK_SIZE)
|
||||
event.content = chars.slice(0, index).join('')
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'streaming')
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
if (index % STEWARD_DELEGATED_THINKING_CHUNK_SIZE === 0 || index === chars.length) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
@@ -304,14 +804,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const text = String(finalText || '')
|
||||
message.text = ''
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.suggestedActions = pendingSuggestedActions
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
const chars = Array.from(text)
|
||||
for (let index = 0; index < chars.length; index += 1) {
|
||||
for (let index = 0; index < chars.length;) {
|
||||
await waitStewardDelegatedTick(STEWARD_DELEGATED_TYPEWRITER_INTERVAL_MS)
|
||||
message.text = chars.slice(0, index + 1).join('')
|
||||
index = Math.min(chars.length, index + STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE)
|
||||
message.text = chars.slice(0, index).join('')
|
||||
message.meta = [STEWARD_ASSISTANT_NAME, '输出中']
|
||||
message.stewardPlan = buildStewardDelegatedPlan(continuation, [...typedEvents], 'typing')
|
||||
if ((index + 1) % 4 === 0 || index === chars.length - 1) {
|
||||
if (index % STEWARD_DELEGATED_TYPEWRITER_CHUNK_SIZE === 0 || index === chars.length) {
|
||||
nextTick(scrollToBottom)
|
||||
}
|
||||
}
|
||||
@@ -670,7 +1172,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
return currentUser.value || user
|
||||
}
|
||||
|
||||
async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null, sessionTypeOverride = '') {
|
||||
async function buildApplicationPreviewWithModelReview(
|
||||
rawText,
|
||||
businessTimeContext = null,
|
||||
sessionTypeOverride = '',
|
||||
options = {}
|
||||
) {
|
||||
const user = await resolveApplicationPreviewUser()
|
||||
const localPreview = applyApplicationBusinessTimeContext(
|
||||
buildLocalApplicationPreview(rawText, user),
|
||||
@@ -697,6 +1204,16 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
if (options.skipModelReview) {
|
||||
return {
|
||||
applicationPreview: await enrichWithPolicyEstimate({
|
||||
...localPreview,
|
||||
modelReviewStatus: 'skipped'
|
||||
}),
|
||||
meta: ['申请核对预览', '结构化快路径']
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const ontology = await fetchOntologyParse(
|
||||
{
|
||||
@@ -828,7 +1345,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||
|
||||
if (shouldUseBudgetCompileReport(rawText, {
|
||||
if (!stewardDelegated && shouldUseBudgetCompileReport(rawText, {
|
||||
sessionType: effectiveSessionType,
|
||||
entrySource: props.entrySource,
|
||||
budgetContext: props.initialBudgetContext
|
||||
@@ -944,9 +1461,62 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
||||
rawText,
|
||||
selectedBusinessTimeContext,
|
||||
effectiveSessionType
|
||||
effectiveSessionType,
|
||||
{
|
||||
skipModelReview: Boolean(stewardDelegated && options.skipApplicationModelReview)
|
||||
}
|
||||
)
|
||||
const reviewStatus = String(meta?.[1] || '').trim()
|
||||
let applicationDateConflict = null
|
||||
try {
|
||||
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
|
||||
} catch (error) {
|
||||
console.warn('Failed to check overlapping application dates:', error)
|
||||
}
|
||||
|
||||
if (applicationDateConflict) {
|
||||
const conflictText = buildApplicationDateConflictMessage(applicationDateConflict)
|
||||
const conflictActions = buildApplicationDateConflictActions(applicationDateConflict)
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
'application-review-preview',
|
||||
'检测到同日期已有申请,已停止重复创建',
|
||||
Date.now() - reviewStartedAt
|
||||
)
|
||||
replaceMessage(pendingMessage.id, createMessage(
|
||||
'assistant',
|
||||
conflictText,
|
||||
[],
|
||||
{
|
||||
meta: ['申请日期冲突'],
|
||||
suggestedActions: conflictActions,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
))
|
||||
} else {
|
||||
await typeStewardDelegatedMessage(
|
||||
pendingMessage.id,
|
||||
conflictText,
|
||||
{
|
||||
meta: [STEWARD_ASSISTANT_NAME, '申请日期冲突'],
|
||||
suggestedActions: conflictActions,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
rawText,
|
||||
applicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
}
|
||||
persistSessionState()
|
||||
nextTick(scrollToBottom)
|
||||
return null
|
||||
}
|
||||
|
||||
if (!stewardDelegated) {
|
||||
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
|
||||
completeFlowStep(
|
||||
@@ -960,16 +1530,43 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
)
|
||||
}
|
||||
if (stewardDelegated) {
|
||||
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
|
||||
applicationPreview,
|
||||
buildLocalApplicationPreviewMessage(applicationPreview)
|
||||
)
|
||||
const localPauseForMissingFields = shouldPauseStewardApplicationPreview(applicationPreview)
|
||||
const shouldFetchSlotDecision = localPauseForMissingFields && !options.skipStewardSlotDecision
|
||||
const slotDecision = shouldFetchSlotDecision
|
||||
? await fetchStewardApplicationSlotDecision(
|
||||
applicationPreview,
|
||||
rawText,
|
||||
options.stewardContinuation || null
|
||||
)
|
||||
: null
|
||||
const slotDecisionActions = buildStewardSlotDecisionSuggestedActions(slotDecision, applicationPreview)
|
||||
const pauseForMissingFields = slotDecision
|
||||
? String(slotDecision.next_action || '').trim() === 'ask_user'
|
||||
: localPauseForMissingFields
|
||||
const stewardApplicationText = buildStewardSlotDecisionMessage(
|
||||
slotDecision,
|
||||
applicationPreview,
|
||||
fallbackStewardApplicationText
|
||||
)
|
||||
await typeStewardDelegatedMessage(
|
||||
pendingMessage.id,
|
||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||
stewardApplicationText,
|
||||
{
|
||||
meta,
|
||||
applicationPreview,
|
||||
applicationPreview: pauseForMissingFields ? null : applicationPreview,
|
||||
suggestedActions: slotDecisionActions.length
|
||||
? slotDecisionActions
|
||||
: buildStewardApplicationPreviewSuggestedActions(applicationPreview),
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
rawText,
|
||||
applicationPreview,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
@@ -1478,6 +2075,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
||||
},
|
||||
{
|
||||
sessionType: effectiveSessionType,
|
||||
rawText,
|
||||
fileNames: effectiveFileNames,
|
||||
stewardContinuation: options.stewardContinuation || null
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user