feat: 报销审批流重构与管家计划全链路贯通

- 重构报销状态注册表、审批流路由与平台风险标记
- 完善管家意图规划器与模型计划构建器全链路
- 新增 OCR Worker 脚本、数据库会话管理与通知状态
- 优化文档中心、日志视图、预算中心与员工管理交互
- 增强工作台摘要、图标资源与全局主题样式
- 补充审批路由、状态注册、OCR 服务与管家规划器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-06 17:19:07 +08:00
parent f60cebadb8
commit e124e4bbcb
162 changed files with 9161 additions and 1941 deletions

View File

@@ -2,10 +2,10 @@ import { computed, onMounted, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue'
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { fetchEmployeeMeta } from '../../services/employees.js'
@@ -47,6 +47,10 @@ function mapOptions(values, suffix = '') {
}))
}
function resolveOptionLabel(options, value, fallback) {
return (Array.isArray(options) ? options : []).find((option) => option.value === value)?.label || fallback
}
function resolveBudgetUpdatedAt(row) {
return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-'
}
@@ -99,8 +103,8 @@ export default {
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
DocumentDropdownFilter,
EnterprisePagination,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
TableEmptyState,
@@ -116,6 +120,7 @@ export default {
const budgetLoading = ref(true)
const budgetError = ref('')
const selectedBudgetId = ref('')
const activeBudgetFilterKey = ref('')
const filters = ref({
year: '2026',
quarter: 'Q1',
@@ -158,6 +163,9 @@ export default {
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
)
const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value)))
const budgetYearFilterLabel = computed(() => resolveOptionLabel(yearOptions, filters.value.year, '年度'))
const budgetQuarterFilterLabel = computed(() => resolveOptionLabel(quarterOptions, filters.value.quarter, '季度'))
const budgetStatusFilterLabel = computed(() => resolveOptionLabel(statusOptions.value, filters.value.status, '状态'))
const filteredBudgetRows = computed(() =>
activeScopeRows.value
@@ -322,6 +330,15 @@ export default {
budgetPage.value = 1
}
function toggleBudgetFilter(key) {
activeBudgetFilterKey.value = activeBudgetFilterKey.value === key ? '' : key
}
function selectBudgetFilter(key, value) {
filters.value[key] = value
activeBudgetFilterKey.value = ''
}
function resolveScopedDepartments(options) {
if (!isDepartmentBudgetMonitor.value) return options
@@ -419,6 +436,7 @@ export default {
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
activeBudgetFilterKey,
activeBudgetScope,
budgetError,
budgetKeyword,
@@ -427,6 +445,9 @@ export default {
budgetPageSize,
budgetPageSizeOptions,
budgetScopeTabs,
budgetQuarterFilterLabel,
budgetStatusFilterLabel,
budgetYearFilterLabel,
backToList,
canAuditBudgetDrafts,
canEditBudget,
@@ -447,8 +468,10 @@ export default {
showEmpty,
showTable,
statusOptions,
selectBudgetFilter,
totalBudgetPages,
totalBudgetRows,
toggleBudgetFilter,
visibleBudgetRows,
yearOptions
}

View File

@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import DocumentDropdownFilter from '../../components/shared/DocumentDropdownFilter.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
@@ -449,10 +450,18 @@ function buildEmployeeSummary(employees) {
}
}
function mapSimpleFilterOptions(values, allLabel) {
return [
{ label: allLabel, value: '' },
...values.map((value) => ({ label: value, value }))
]
}
export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
DocumentDropdownFilter,
EnterprisePagination,
EnterpriseSelect,
TableLoadingState,
@@ -559,6 +568,12 @@ export default {
)
)
)
const departmentFilterOptions = computed(() => mapSimpleFilterOptions(departmentOptions.value, '全部部门'))
const gradeFilterOptions = computed(() => mapSimpleFilterOptions(gradeOptions.value, '全部职级'))
const roleDropdownOptions = computed(() => mapSimpleFilterOptions(roleFilterOptions.value, '全部角色'))
const departmentFilterLabel = computed(() => selectedDepartment.value || '组织部门')
const gradeFilterLabel = computed(() => selectedGrade.value || '职级')
const roleFilterLabel = computed(() => selectedRole.value || '系统角色')
const managerOptions = computed(() => {
const currentId = selectedEmployee.value?.id
@@ -1440,6 +1455,12 @@ export default {
selectedDepartment,
selectedGrade,
selectedRole,
departmentFilterLabel,
departmentFilterOptions,
gradeFilterLabel,
gradeFilterOptions,
roleDropdownOptions,
roleFilterLabel,
activeFilterPopover,
currentPage,
pageSize,

File diff suppressed because it is too large Load Diff

View File

@@ -203,7 +203,7 @@ export function shouldUseBudgetCompileReport(rawText, options = {}) {
const isBudgetContextEditIntent = isBudgetContext && hasBudgetKeyword && hasCompileKeyword
return Boolean(
budgetContext ||
(isBudgetContext && budgetContext) ||
(
text &&
(isWholeBudgetCompileIntent || isBudgetContextPeriodIntent || isBudgetContextEditIntent)

View File

@@ -0,0 +1,179 @@
import { computed, reactive, ref } from 'vue'
export const RECEIPT_FILTER_ALL = 'all'
const QUALITY_OPTIONS = [
{ value: RECEIPT_FILTER_ALL, label: '全部置信度' },
{ value: 'high', label: '高置信度' },
{ value: 'medium', label: '中等置信度' },
{ value: 'low', label: '低置信度' },
{ value: 'missing', label: '待确认' }
]
function normalizeText(value) {
return String(value ?? '').trim()
}
function getFilterValue(filters, key) {
return normalizeText(filters?.[key]) || RECEIPT_FILTER_ALL
}
function buildUniqueOptions(rows, valueKey, labelKey, allLabel) {
const seen = new Map()
for (const row of Array.isArray(rows) ? rows : []) {
const value = normalizeText(row?.[valueKey])
if (!value || seen.has(value)) continue
seen.set(value, normalizeText(row?.[labelKey]) || value)
}
return [
{ value: RECEIPT_FILTER_ALL, label: allLabel },
...Array.from(seen.entries())
.map(([value, label]) => ({ value, label }))
.sort((left, right) => left.label.localeCompare(right.label, 'zh-Hans-CN'))
]
}
function resolveReceiptMonth(row) {
const raw = normalizeText(row?.document_date) || normalizeText(row?.uploaded_at)
const match = raw.match(/^(\d{4})[-/年]?(\d{1,2})/)
if (!match) return ''
return `${match[1]}-${String(match[2]).padStart(2, '0')}`
}
function buildMonthOptions(rows) {
const months = new Set((Array.isArray(rows) ? rows : []).map(resolveReceiptMonth).filter(Boolean))
return [
{ value: RECEIPT_FILTER_ALL, label: '全部月份' },
...Array.from(months)
.sort((left, right) => right.localeCompare(left))
.map((value) => ({ value, label: `${value.replace('-', '年')}` }))
]
}
function resolveScore(row) {
const score = Number(row?.avg_score || 0)
return Number.isFinite(score) ? score : 0
}
function matchesQuality(row, quality) {
if (quality === RECEIPT_FILTER_ALL) return true
const score = resolveScore(row)
if (quality === 'missing') return score <= 0
if (quality === 'high') return score >= 0.9
if (quality === 'medium') return score >= 0.75 && score < 0.9
if (quality === 'low') return score > 0 && score < 0.75
return true
}
export function buildReceiptFilterControls(rows, filters) {
return [
{
key: 'documentType',
label: '票据类型',
options: buildUniqueOptions(rows, 'document_type', 'document_type_label', '全部类型')
},
{
key: 'scene',
label: '费用场景',
options: buildUniqueOptions(rows, 'scene_code', 'scene_label', '全部场景')
},
{
key: 'month',
label: '票据月份',
options: buildMonthOptions(rows)
},
{
key: 'quality',
label: '置信度',
options: QUALITY_OPTIONS
}
].map((control) => ({
...control,
value: getFilterValue(filters, control.key)
}))
}
export function applyReceiptListFilters(rows, filters) {
const documentType = getFilterValue(filters, 'documentType')
const scene = getFilterValue(filters, 'scene')
const month = getFilterValue(filters, 'month')
const quality = getFilterValue(filters, 'quality')
return (Array.isArray(rows) ? rows : []).filter((row) => (
(documentType === RECEIPT_FILTER_ALL || normalizeText(row?.document_type) === documentType)
&& (scene === RECEIPT_FILTER_ALL || normalizeText(row?.scene_code) === scene)
&& (month === RECEIPT_FILTER_ALL || resolveReceiptMonth(row) === month)
&& matchesQuality(row, quality)
))
}
export function buildReceiptFilterTokens(controls, filters) {
return (Array.isArray(controls) ? controls : [])
.map((control) => {
const value = getFilterValue(filters, control.key)
if (value === RECEIPT_FILTER_ALL) return ''
const option = control.options.find((item) => item.value === value)
return `${control.label}${option?.label || value}`
})
.filter(Boolean)
}
export function createReceiptFolderListFilterModel({ receipts, activeRows, keyword }) {
const openReceiptFilterKey = ref('')
const receiptFilters = reactive({
documentType: RECEIPT_FILTER_ALL,
scene: RECEIPT_FILTER_ALL,
month: RECEIPT_FILTER_ALL,
quality: RECEIPT_FILTER_ALL
})
const receiptFilterControls = computed(() => buildReceiptFilterControls(receipts.value, receiptFilters))
const hasActiveReceiptFilters = computed(() => Object.values(receiptFilters).some((value) => value !== RECEIPT_FILTER_ALL))
const filteredRows = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
const filtered = applyReceiptListFilters(activeRows.value, receiptFilters)
if (!normalized) return filtered
return filtered.filter((item) => [
item.file_name,
item.document_type_label,
item.scene_label,
item.summary,
item.amount,
item.document_date,
item.linked_claim_no
].filter(Boolean).join('').toLowerCase().includes(normalized))
})
function toggleReceiptFilter(key) {
openReceiptFilterKey.value = openReceiptFilterKey.value === key ? '' : key
}
function selectReceiptFilter(key, value) {
receiptFilters[key] = value
openReceiptFilterKey.value = ''
}
function resolveReceiptFilterLabel(control) {
return control.options.find((option) => option.value === receiptFilters[control.key])?.label || control.label
}
function clearReceiptFilters() {
receiptFilters.documentType = RECEIPT_FILTER_ALL
receiptFilters.scene = RECEIPT_FILTER_ALL
receiptFilters.month = RECEIPT_FILTER_ALL
receiptFilters.quality = RECEIPT_FILTER_ALL
openReceiptFilterKey.value = ''
}
return {
filteredRows,
hasActiveReceiptFilters,
openReceiptFilterKey,
receiptFilterControls,
receiptFilters,
clearReceiptFilters,
resolveReceiptFilterLabel,
selectReceiptFilter,
toggleReceiptFilter
}
}

View File

@@ -0,0 +1,131 @@
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
const APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_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_PREVIEW_FIELD_LABEL_MAP = {
applicationType: '申请类型',
time: '时间',
location: '地点',
reason: '事由',
amount: '金额',
transportMode: '出行方式',
department: '所属部门',
applicant: '申请人',
grade: '职级'
}
function compactValue(value = '') {
return String(value || '').trim()
}
function resolveStewardCurrentTask(continuation = null) {
const task = continuation?.currentTask || continuation?.current_task || null
return task && typeof task === 'object' ? task : null
}
function resolveTaskOntologyFields(task = null) {
const fields = task?.ontology_fields || task?.ontologyFields || {}
return fields && typeof fields === 'object' ? fields : {}
}
function resolveFieldValue(...candidates) {
for (const candidate of candidates) {
const value = compactValue(candidate)
if (value && !['待补充', '待测算', '未知'].includes(value)) {
return value
}
}
return ''
}
function buildUpdatedTask(task = null, fieldKey = '', value = '') {
if (!task || typeof task !== 'object') {
return null
}
const canonicalKey = APPLICATION_PREVIEW_FIELD_ONTOLOGY_KEY_MAP[fieldKey] || ''
if (!canonicalKey) {
return { ...task }
}
const ontologyFields = {
...resolveTaskOntologyFields(task),
[canonicalKey]: value
}
const sourceMissingFields = Array.isArray(task.missing_fields)
? task.missing_fields
: Array.isArray(task.missingFields)
? task.missingFields
: []
return {
...task,
ontology_fields: ontologyFields,
missing_fields: sourceMissingFields.filter((field) => compactValue(field) !== canonicalKey)
}
}
export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') {
const source = continuation && typeof continuation === 'object' ? continuation : {}
const currentTask = resolveStewardCurrentTask(source)
const updatedTask = buildUpdatedTask(currentTask, fieldKey, value)
if (!updatedTask) {
return source
}
return {
...source,
currentTask: updatedTask
}
}
export function buildStewardFieldCompletionRawText({
preview = {},
fieldKey = '',
fieldLabel = '',
value = '',
continuation = null
} = {}) {
const normalizedPreview = normalizeApplicationPreview(preview)
const fields = normalizedPreview.fields || {}
const currentTask = resolveStewardCurrentTask(continuation)
const ontologyFields = resolveTaskOntologyFields(currentTask)
const selectedLabel = compactValue(fieldLabel) || APPLICATION_PREVIEW_FIELD_LABEL_MAP[fieldKey] || '补充项'
const selectedValue = compactValue(value)
const transportMode = fieldKey === 'transportMode'
? selectedValue
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
const knownLines = [
['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')],
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
['地点', resolveFieldValue(fields.location, ontologyFields.location)],
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
['天数', resolveFieldValue(fields.days)],
['出行方式', transportMode]
]
.filter(([, fieldValue]) => fieldValue)
.map(([label, fieldValue]) => `${label}${fieldValue}`)
return [
'小财管家继续执行申请单字段补齐。',
`用户已补充:${selectedLabel}${selectedValue}`,
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
'',
'已识别信息:',
...knownLines,
'',
'处理要求:请先根据已补齐字段模拟查询交通票据和费用口径,完成系统预估金额测算,再生成申请单核对表。',
'如果仍有缺失信息,继续向用户追问;不要替用户默认填写,也不要跳过小财管家的思考过程。'
].filter((line) => line !== '').join('\n')
}

View File

@@ -79,6 +79,25 @@ const FIELD_ALIASES = {
application_transport_mode: 'transport_mode'
}
const APPLICATION_NON_BLOCKING_MISSING_FIELDS = new Set([
'amount',
'attachments',
'employee_no',
'employee_name',
'department_name'
])
const FIELD_VALUE_DISPLAY_CONFIG = {
expense_type: {
travel: '差旅',
business_entertainment: '业务招待',
transportation: '交通费',
traffic: '交通费',
accommodation: '住宿费',
meal: '餐饮费'
}
}
export function buildStewardPlanRequest({ rawText = '', files = [], currentUser = {} } = {}) {
const safeFiles = Array.isArray(files) ? files : []
return {
@@ -123,9 +142,10 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
tasks: Array.isArray(rawPlan.tasks)
? rawPlan.tasks.map((item) => {
const taskType = String(item.task_type || item.taskType || '')
const missingFields = Array.isArray(item.missing_fields || item.missingFields)
const rawMissingFields = Array.isArray(item.missing_fields || item.missingFields)
? item.missing_fields || item.missingFields
: []
const missingFields = filterStewardBlockingMissingFields(rawMissingFields, taskType)
return {
taskId: String(item.task_id || item.taskId || ''),
taskType,
@@ -188,7 +208,7 @@ export function buildStewardPlanMessageText(plan) {
}
export function buildStewardFieldItems(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const safeFields = filterStewardBlockingMissingFields(fields, taskType)
const seen = new Set()
return safeFields
.map((field) => normalizeFieldKey(field))
@@ -202,18 +222,44 @@ export function buildStewardFieldItems(fields = [], taskType = '') {
.map((field) => resolveFieldDisplay(field, taskType))
}
export function formatStewardMissingFieldList(fields = [], taskType = '') {
export function formatStewardMissingFieldList(fields = [], taskType = '', options = {}) {
const includeHints = options.includeHints !== false
return buildStewardFieldItems(fields, taskType)
.map((item) => item.hint ? `${item.label}${item.hint}` : item.label)
.map((item) => includeHints && item.hint ? `${item.label}${item.hint}` : item.label)
.join('、')
}
export function filterStewardBlockingMissingFields(fields = [], taskType = '') {
const safeFields = Array.isArray(fields) ? fields : []
const seen = new Set()
if (taskType !== 'expense_application') {
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field)) {
return false
}
seen.add(field)
return true
})
}
return safeFields
.map((field) => normalizeFieldKey(field))
.filter((field) => {
if (!field || seen.has(field) || APPLICATION_NON_BLOCKING_MISSING_FIELDS.has(field)) {
return false
}
seen.add(field)
return true
})
}
export function formatStewardOntologyFields(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return `${field.label}${value}`
return `${field.label}${formatStewardFieldDisplayValue(field.key, value)}`
})
.join('')
}
@@ -246,6 +292,7 @@ export function buildStewardSuggestedActions(plan) {
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
steward_plan_id: normalized.planId,
steward_next_task_id: task?.taskId || '',
steward_current_task: buildStewardTaskPayload(task),
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
}
@@ -447,7 +494,11 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
}
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
const missingFields = formatStewardMissingFieldList(task.missingFields || [], task.taskType)
const missingFields = formatStewardMissingFieldList(
task.missingFields || [],
task.taskType,
{ includeHints: false }
)
const lines = [
actionType === 'confirm_create_application'
? `小财管家已完成意图识别,请先创建申请单:${task.title || task.taskTypeLabel}`
@@ -458,8 +509,12 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
group?.excludedAttachmentNames?.length ? `暂不归集附件:${group.excludedAttachmentNames.join('、')}` : '',
missingFields ? `还需要补充:${missingFields}` : '',
actionType === 'confirm_create_application'
? '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
? missingFields
? '请先追问上述缺失信息,不要直接生成申请单核对表,也不要替用户默认填写。'
: '请直接生成申请单核对结果;信息足够时生成申请单,但在入库或提交审批前仍需让我确认。'
: missingFields
? '请先追问上述缺失信息,不要直接生成报销核对结果,也不要替用户默认填写。'
: '请直接生成报销核对结果;需要创建草稿、绑定附件或提交审批前仍需让我确认。'
]
const remainingTaskText = normalized ? buildRemainingTaskText(normalized, task.taskId) : ''
if (remainingTaskText) {
@@ -495,6 +550,12 @@ function resolveFieldDisplay(field, taskType = '') {
}
}
function formatStewardFieldDisplayValue(field, value) {
const key = normalizeFieldKey(field)
const normalizedValue = String(value || '').trim()
return FIELD_VALUE_DISPLAY_CONFIG[key]?.[normalizedValue] || normalizedValue
}
function buildRemainingTaskText(normalized, currentTaskId) {
const remainingTasks = normalized.tasks.filter((task) => task.taskId !== currentTaskId)
if (!remainingTasks.length) {
@@ -512,13 +573,20 @@ function buildRemainingTaskText(normalized, currentTaskId) {
function buildRemainingTaskPayload(normalized, currentTaskId) {
return normalized.tasks
.filter((task) => task.taskId !== currentTaskId)
.map((task) => ({
task_id: task.taskId,
task_type: task.taskType,
title: task.title,
summary: task.summary,
assigned_agent: task.assignedAgent,
ontology_fields: task.ontologyFields || {},
missing_fields: task.missingFields || []
}))
.map((task) => buildStewardTaskPayload(task))
}
function buildStewardTaskPayload(task) {
if (!task) {
return null
}
return {
task_id: task.taskId || task.task_id || '',
task_type: task.taskType || task.task_type || '',
title: task.title || '',
summary: task.summary || '',
assigned_agent: task.assignedAgent || task.assigned_agent || '',
ontology_fields: task.ontologyFields || task.ontology_fields || {},
missing_fields: task.missingFields || task.missing_fields || []
}
}

View File

@@ -6,8 +6,10 @@ import {
} from './stewardPlanModel.js'
import { SESSION_TYPE_STEWARD } from './travelReimbursementConversationModel.js'
const STEWARD_TYPEWRITER_INTERVAL_MS = 18
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 14
const STEWARD_TYPEWRITER_INTERVAL_MS = 10
const STEWARD_THINKING_TYPEWRITER_INTERVAL_MS = 8
const STEWARD_TYPEWRITER_CHUNK_SIZE = 4
const STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE = 5
export function useStewardPlanFlow({
activeSessionType,
@@ -174,7 +176,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
index = Math.min(total, index + STEWARD_TYPEWRITER_CHUNK_SIZE)
const message = messages.value.find((item) => item.id === messageId)
if (!message) {
return
@@ -269,7 +271,7 @@ export function useStewardPlanFlow({
if (runId !== stewardTypewriterRunId) {
return
}
index += 1
index = Math.min(chars.length, index + STEWARD_THINKING_TYPEWRITER_CHUNK_SIZE)
updateStewardThinkingEvent(messageId, eventId, chars.slice(0, index).join(''), 'running', runId)
}

View File

@@ -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
}
)