feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -134,20 +134,26 @@ export default {
Boolean(selectedSkill.value?.id) &&
!normalizeText(selectedSkill.value?.publishedVersion).replace('-', '')
)
const canOpenRiskRuleReviewSubmit = computed(
() => false
)
const canOpenRiskRuleReviewSubmit = computed(() => false)
const canSubmitRiskRuleReview = computed(
() =>
canOpenRiskRuleReviewSubmit.value &&
riskRuleTestPassed.value
)
const canReturnRiskRule = computed(
() => false
)
const canReturnRiskRule = computed(() => false)
const riskRuleHasPublishableRevision = computed(() => {
const revision = selectedSkill.value?.configJson?.revision_draft
return selectedSkillUsesJsonRisk.value && revision &&
revision.generation_status === 'completed' &&
normalizeText(selectedSkill.value?.workingVersion).replace('-', '') &&
selectedSkill.value?.workingVersion !== selectedSkill.value?.publishedVersion
})
const canPublishRiskRule = computed(
() =>
false
Boolean(riskRuleHasPublishableRevision.value) &&
canManageSelected.value &&
riskRuleTestPassed.value &&
!detailBusy.value
)
const canToggleRiskRuleEnabled = computed(
() => selectedSkillUsesJsonRisk.value && canManageSelected.value
@@ -375,6 +381,7 @@ export default {
canDeleteRiskRule,
canReturnRiskRule,
canPublishRiskRule,
riskRuleHasPublishableRevision,
canToggleRiskRuleEnabled,
canEditRiskRuleDraft,
canCreateRiskRuleRevision,

View File

@@ -1,13 +1,12 @@
import { computed, onMounted, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElInput } from 'element-plus/es/components/input/index.mjs'
import { ElPagination } from 'element-plus/es/components/pagination/index.mjs'
import { ElTable, ElTableColumn } from 'element-plus/es/components/table/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { fetchBudgetSummary } from '../../services/budgets.js'
import { fetchEmployeeMeta } from '../../services/employees.js'
import {
canEditBudgetCenter,
@@ -17,10 +16,19 @@ import {
} from '../../utils/accessControl.js'
import {
BUDGET_QUARTER_OPTIONS,
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
BUDGET_YEAR_OPTIONS,
resolveBudgetExpenseTypeLabel
BUDGET_YEAR_OPTIONS
} from '../../utils/budgetOntology.js'
import {
BUDGET_PAGE_SIZE_OPTIONS,
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
buildBudgetRows,
buildBudgetScopeTabs,
buildBudgetUsageData,
getBudgetStatusOptions,
matchesBudgetKeyword
} from './budgetCenterListModel.js'
const FALLBACK_DEPARTMENTS = [
{ code: 'MARKET-DEPT', name: '市场部', costCenter: 'CC-4100' },
@@ -31,182 +39,52 @@ const FALLBACK_DEPARTMENTS = [
{ code: 'PRESIDENT-OFFICE', name: '总裁办', costCenter: 'CC-1000' }
]
const EXPENSE_BUDGET_SEED = {
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80, action: '提醒' },
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70, action: '正常' },
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80, action: '管控' },
office: { total: 180000, used: 68500, occupied: 32000, warning: 70, action: '正常' }
function mapOptions(values, suffix = '') {
return values.map((value) => ({
label: suffix ? `${value}${suffix}` : value,
value
}))
}
const DEFAULT_EXPENSE_BUDGET = {
total: 100000,
used: 0,
occupied: 0,
warning: 70,
action: '正常'
function resolveBudgetUpdatedAt(row) {
return row?.updatedAt || row?.submittedAt || row?.archivedAt || '-'
}
const EXPENSE_BLUEPRINTS = BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option) => ({
...DEFAULT_EXPENSE_BUDGET,
...EXPENSE_BUDGET_SEED[option.value],
budgetSubjectCode: option.value,
expenseType: option.label
}))
const currency = (value) =>
Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
const comparison = (value, direction) => ({
value,
tone: direction === 'down' ? 'down' : 'up',
icon: direction === 'down' ? 'mdi mdi-arrow-down' : 'mdi mdi-arrow-up'
})
const BUDGET_PAGE_SIZE_OPTIONS = [5, 10]
const ALERT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit'
})
const BUDGET_COMPILED_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
const normalizePeriodKey = (year, quarter) => {
const normalizedYear = String(year || '').replace(/[^\d]/g, '') || '2026'
const normalizedQuarter = BUDGET_QUARTER_OPTIONS.includes(String(quarter || '').trim())
? String(quarter || '').trim()
: BUDGET_QUARTER_OPTIONS[0]
return `${normalizedYear}${normalizedQuarter}`
function resolveBudgetCompiler(row) {
return row?.compiler || row?.owner || '-'
}
const parsePercent = (value, fallback = 80) => {
const parsed = Number(String(value || '').replace(/[^\d.-]/g, ''))
return Number.isFinite(parsed) ? parsed : fallback
}
const clampPercent = (value) => Math.min(100, Math.max(0, Number(value) || 0))
function buildThresholds(warning) {
const alert = clampPercent(warning)
return {
reminder: clampPercent(alert - 10),
alert,
risk: clampPercent(alert + 10)
}
}
function formatBudgetCompiledAt(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
return BUDGET_COMPILED_TIME_FORMATTER.format(date).replace(/\//g, '-')
}
function resolveBudgetCompiler(item) {
return String(
item?.compiler
|| item?.compiled_by
|| item?.compiledBy
|| item?.created_by
|| item?.createdBy
|| item?.owner_name
|| item?.ownerName
|| '预算编制助手'
).trim()
}
function resolveBudgetReviewer(item) {
return String(
item?.reviewer
|| item?.reviewed_by
|| item?.reviewedBy
|| item?.approved_by
|| item?.approvedBy
|| item?.auditor
|| item?.updated_by
|| item?.updatedBy
|| '高级财务人员'
).trim()
}
function normalizeBudgetAllocationRow(item) {
const balance = item?.balance || {}
const totalAmount = Number(balance.total_amount ?? item?.original_amount ?? 0)
const usedAmount = Number(balance.consumed_amount ?? 0)
const occupiedAmount = Number(balance.reserved_amount ?? 0)
const leftAmount = Number(balance.available_amount ?? 0)
const rate = Number(balance.usage_rate ?? 0)
const warning = parsePercent(item?.warning_threshold, 80)
const thresholds = buildThresholds(warning)
const budgetSubjectCode = String(item?.subject_code || '').trim()
const expenseType = item?.subject_name || resolveBudgetExpenseTypeLabel(budgetSubjectCode, budgetSubjectCode)
return {
allocationId: item?.id || '',
budgetNo: item?.budget_no || '',
budgetSubjectCode,
compiledAt: formatBudgetCompiledAt(item?.created_at || item?.createdAt || item?.updated_at || item?.updatedAt),
compiler: resolveBudgetCompiler(item),
reviewer: resolveBudgetReviewer(item),
expenseType,
totalAmount,
usedAmount,
occupiedAmount,
leftAmount,
rate,
rateTone: rate >= thresholds.risk ? 'danger' : rate >= thresholds.alert ? 'warn' : 'ok',
reminderThreshold: thresholds.reminder,
alertThreshold: thresholds.alert,
riskThreshold: thresholds.risk,
reminderLine: `${thresholds.reminder}%`,
alertLine: `${thresholds.alert}%`,
riskLine: `${thresholds.risk}%`,
total: currency(totalAmount),
used: currency(usedAmount),
occupied: currency(occupiedAmount),
left: currency(leftAmount)
}
}
function normalizeBudgetUsageData(rows) {
const source = Array.isArray(rows) ? rows : []
return {
labels: source.map((item) => item.expenseType || '未分类'),
budget: source.map((item) => Number(item.totalAmount || 0)),
used: source.map((item) => Number(item.usedAmount || 0)),
occupied: source.map((item) => Number(item.occupiedAmount || 0)),
available: source.map((item) => Math.max(Number(item.leftAmount || 0), 0))
}
}
function formatAlertDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return ALERT_DATE_FORMATTER.format(date)
}
function normalizeBudgetWarning(item) {
const subjectName = item?.subject_name || resolveBudgetExpenseTypeLabel(item?.subject_code, item?.subject_code)
const departmentName = item?.department_name || ''
const usageRate = Number(item?.usage_rate || 0)
const warningThreshold = Number(item?.warning_threshold || 0)
const tone = item?.severity === 'danger' ? 'danger' : 'warn'
return {
id: item?.allocation_id || `${departmentName}-${subjectName}-${item?.period_key || ''}`,
title: departmentName ? `${departmentName} · ${subjectName}` : subjectName,
desc: item?.message || `使用率已达 ${usageRate}%,达到预警线 ${warningThreshold}%。`,
date: formatAlertDate(item?.occurred_at),
tone
}
function buildBudgetDetailKpis(row) {
return [
{
label: '编制人',
value: resolveBudgetCompiler(row),
unit: '',
meta: row.scope === BUDGET_SCOPE_REVIEW ? '提交草案' : '预算编制',
color: 'var(--theme-primary)'
},
{
label: '审核人',
value: row.reviewer || '-',
unit: '',
meta: '高级财务审核',
color: '#3b82f6'
},
{
label: '版本',
value: row.version || '-',
unit: '',
meta: row.periodType || '预算版本',
color: '#64748b'
},
{
label: '更新时间',
value: resolveBudgetUpdatedAt(row),
unit: '',
meta: '最近同步',
color: '#f59e0b'
}
]
}
export default {
@@ -217,91 +95,71 @@ export default {
default: () => ({})
}
},
emits: ['openAssistant'],
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
TableEmptyState,
TableLoadingState,
ElButton,
ElInput,
ElPagination,
ElTable,
ElTableColumn
ElButton
},
setup(props, { emit }) {
const departments = ref(FALLBACK_DEPARTMENTS)
const activeDepartmentCode = ref(FALLBACK_DEPARTMENTS[0].code)
const departmentKeyword = ref('')
const activeBudgetScope = ref(BUDGET_SCOPE_ALL)
const budgetKeyword = ref('')
const budgetPage = ref(1)
const budgetPageSize = ref(8)
const budgetLoading = ref(true)
const budgetError = ref('')
const selectedBudgetId = ref('')
const filters = ref({
year: '2026',
quarter: 'Q1',
expenseType: '全部',
status: '全部'
})
const budgetPage = ref(1)
const budgetPageSize = ref(5)
const budgetTableKeyword = ref('')
const budgetRows = ref([])
const budgetSummary = ref(null)
const budgetLoading = ref(true)
const budgetError = ref('')
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser))
const canAuditBudgetDrafts = computed(() => canEditBudgetCenter(props.currentUser))
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
const isDepartmentBudgetMonitor = computed(
() => isBudgetMonitorUser(props.currentUser) && !canSwitchDepartments.value && !isExecutiveUser(props.currentUser)
)
const yearOptions = BUDGET_YEAR_OPTIONS.map((year) => ({ label: `${year}年度`, value: year }))
const budgetPageSizeOptions = BUDGET_PAGE_SIZE_OPTIONS.map((size) => ({ label: `${size} 条/页`, value: size }))
const departmentOptions = computed(() =>
departments.value.map((department) => ({
label: department.name,
value: department.code
}))
)
const activeDepartment = computed(() =>
departments.value.find((item) => item.code === activeDepartmentCode.value) || departments.value[0]
)
const activeDepartmentName = computed(() => activeDepartment.value?.name || '市场部')
const currentUserDepartmentName = computed(() =>
String(props.currentUser?.departmentName || props.currentUser?.department || '').trim()
)
const currentUserCostCenter = computed(() =>
String(props.currentUser?.costCenter || props.currentUser?.cost_center || '').trim()
)
const departmentRows = computed(() => budgetRows.value)
const filteredBudgetRows = computed(() => {
const keyword = budgetTableKeyword.value.trim().toLowerCase()
return departmentRows.value
.filter((row) => {
if (!keyword) return true
return [
row.compiledAt,
row.compiler,
row.reviewer,
row.expenseType,
row.total,
row.used,
row.occupied,
row.left,
`${row.rate}%`,
row.reminderLine,
row.alertLine,
row.riskLine
].some((value) => String(value || '').toLowerCase().includes(keyword))
})
.filter((row) => filters.value.expenseType === '全部' || row.expenseType === filters.value.expenseType)
.filter((row) => {
if (filters.value.status === '全部') return true
if (filters.value.status === '预警') return row.rateTone === 'warn'
if (filters.value.status === '管控') return row.rateTone === 'danger'
return row.rateTone === 'ok'
})
})
const yearOptions = mapOptions(BUDGET_YEAR_OPTIONS, '年度')
const quarterOptions = mapOptions(BUDGET_QUARTER_OPTIONS)
const budgetPageSizeOptions = BUDGET_PAGE_SIZE_OPTIONS.map((size) => ({ label: `${size} 条/页`, value: size }))
const budgetRowsByScope = computed(() =>
buildBudgetRows({
departments: departments.value,
year: filters.value.year,
quarter: filters.value.quarter
})
)
const budgetScopeTabs = computed(() => buildBudgetScopeTabs(budgetRowsByScope.value))
const activeScopeRows = computed(() => budgetRowsByScope.value[activeBudgetScope.value] || [])
const activeScopeLabel = computed(
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
)
const statusOptions = computed(() => mapOptions(getBudgetStatusOptions(activeBudgetScope.value)))
const filteredBudgetRows = computed(() =>
activeScopeRows.value
.filter((row) => filters.value.status === '全部' || row.statusLabel === filters.value.status)
.filter((row) => matchesBudgetKeyword(row, budgetKeyword.value))
)
const totalBudgetRows = computed(() => filteredBudgetRows.value.length)
const totalBudgetPages = computed(() =>
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 5)))
Math.max(1, Math.ceil(totalBudgetRows.value / Number(budgetPageSize.value || 8)))
)
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
@@ -310,102 +168,112 @@ export default {
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 5)
const pageSize = Number(budgetPageSize.value || 8)
const start = (currentBudgetPage.value - 1) * pageSize
return filteredBudgetRows.value.slice(start, start + pageSize)
})
const totals = computed(() => {
const rows = departmentRows.value
const total = rows.reduce((sum, item) => sum + item.totalAmount, 0)
const used = rows.reduce((sum, item) => sum + item.usedAmount, 0)
const occupied = rows.reduce((sum, item) => sum + item.occupiedAmount, 0)
const selectedBudget = computed(() =>
activeScopeRows.value.find((row) => row.id === selectedBudgetId.value) || null
)
const detailMode = computed(() => Boolean(selectedBudget.value))
const selectedBudgetUsageData = computed(() => buildBudgetUsageData(selectedBudget.value))
const budgetDetailTopBarPayload = computed(() => {
const row = selectedBudget.value
if (!row) return null
return {
total,
used,
occupied,
left: Math.max(total - used - occupied, 0)
view: {
eyebrow: '预算详情',
title: `${row.departmentName} · ${row.periodLabel}`,
desc: `${row.budgetNo} / ${row.version} · 仅覆盖差旅、通信、招待费、办公用品`
},
alerts: [],
kpis: buildBudgetDetailKpis(row)
}
})
const selectedBudgetStatusNotes = computed(() => {
const row = selectedBudget.value
if (!row) return []
const budgetMetrics = computed(() => [
{
label: '预算总额',
value: `¥${currency(totals.value.total)}`,
yoy: comparison('+8.42%', 'up'),
mom: comparison('+2.16%', 'up'),
tone: 'primary',
icon: 'mdi mdi-wallet-outline'
},
{
label: '已发生',
value: `¥${currency(totals.value.used)}`,
yoy: comparison('+12.68%', 'up'),
mom: comparison('+4.35%', 'up'),
tone: 'info',
icon: 'mdi mdi-chart-line'
},
{
label: '已占用',
value: `¥${currency(totals.value.occupied)}`,
yoy: comparison('+6.37%', 'up'),
mom: comparison('-1.84%', 'down'),
tone: 'warning',
icon: 'mdi mdi-briefcase-check-outline'
},
{
label: '剩余可用',
value: `¥${currency(totals.value.left)}`,
yoy: comparison('-3.26%', 'down'),
mom: comparison('-2.08%', 'down'),
tone: 'primary',
icon: 'mdi mdi-cash'
}
])
const visibleDepartments = computed(() => {
const keyword = departmentKeyword.value.trim()
return departments.value
.filter((item) => !keyword || item.name.includes(keyword) || item.code.includes(keyword))
.map((item) => ({
...item,
icon: item.code === activeDepartmentCode.value ? 'mdi mdi-account-group-outline' : 'mdi mdi-domain'
}))
return [
{
label: '预算状态',
value: row.statusLabel,
tone: row.statusTone || 'ok',
desc: row.auditSummary || '当前预算状态已完成同步,可在预算中心继续追踪。'
},
{
label: '风险状态',
value: row.riskLabel,
tone: row.riskTone || 'ok',
desc: `当前已发生与已占用合计使用率为 ${row.usageRateLabel},系统按四类费用的提醒、告警和风险阈值综合判断。`
}
]
})
const showTable = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length > 0)
const showEmpty = computed(() => !budgetLoading.value && !budgetError.value && visibleBudgetRows.value.length === 0)
const emptyState = computed(() => ({
eyebrow: activeScopeLabel.value,
title: `暂无${activeScopeLabel.value}`,
desc: '当前筛选条件下没有匹配的预算记录。',
icon: 'mdi mdi-database-search-outline',
tone: 'blue',
artLabel: '预算列表为空',
tips: ['可以调整年度、季度、状态或关键词后重试。']
}))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`)
const warnings = computed(() =>
(Array.isArray(budgetSummary.value?.warnings) ? budgetSummary.value.warnings : [])
.map(normalizeBudgetWarning)
)
const budgetUsageData = computed(() =>
normalizeBudgetUsageData(departmentRows.value)
)
function openBudgetAssistant() {
function openBudgetAssistant(prompt = '') {
if (!canEditBudget.value) return
emit('openAssistant', {
source: 'budget',
sessionType: 'budget',
prompt: '',
prompt,
files: [],
conversation: null
})
}
function openBudgetReviewAssistant(row) {
if (!row || !canAuditBudgetDrafts.value) {
openBudgetDetail(row)
return
}
openBudgetAssistant(
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`
)
}
function openBudgetDetail(row) {
if (!row?.id) return
selectedBudgetId.value = row.id
}
function backToList() {
selectedBudgetId.value = ''
}
function handleRowAction(row) {
if (activeBudgetScope.value === BUDGET_SCOPE_REVIEW && canAuditBudgetDrafts.value) {
openBudgetReviewAssistant(row)
return
}
openBudgetDetail(row)
}
function goToBudgetPage(page) {
budgetPage.value = Math.min(Math.max(Number(page) || 1, 1), totalBudgetPages.value)
}
function changeBudgetPage(direction) {
goToBudgetPage(currentBudgetPage.value + direction)
function changeBudgetPageSize(size) {
budgetPageSize.value = Number(size) || 8
budgetPage.value = 1
}
function resolveScopedDepartments(options) {
if (!isDepartmentBudgetMonitor.value) {
return options
}
if (!isDepartmentBudgetMonitor.value) return options
const userDepartment = currentUserDepartmentName.value
const userCostCenter = currentUserCostCenter.value
@@ -414,9 +282,7 @@ export default {
return userDepartment && item.name === userDepartment
})
if (scoped.length) {
return scoped
}
if (scoped.length) return scoped
return [
{
@@ -430,6 +296,7 @@ export default {
async function loadDepartments() {
budgetLoading.value = true
budgetError.value = ''
try {
const payload = await fetchEmployeeMeta()
const options = Array.isArray(payload?.organizationOptions) ? payload.organizationOptions : []
@@ -442,39 +309,11 @@ export default {
costCenter: String(item.costCenter || '')
}))
const scopedDepartments = resolveScopedDepartments(nextDepartments)
if (scopedDepartments.length) {
departments.value = scopedDepartments
if (!scopedDepartments.some((item) => item.code === activeDepartmentCode.value)) {
activeDepartmentCode.value = scopedDepartments[0].code
}
}
await loadBudgetData()
} catch (error) {
console.warn('Failed to load budget departments from employee meta:', error)
await loadBudgetData()
}
}
async function loadBudgetData() {
const department = activeDepartment.value || {}
budgetLoading.value = true
budgetError.value = ''
try {
const payload = await fetchBudgetSummary({
year: filters.value.year,
period: normalizePeriodKey(filters.value.year, filters.value.quarter),
department_id: department.id || '',
cost_center: department.costCenter || ''
})
const allocations = Array.isArray(payload?.allocations) ? payload.allocations : []
budgetSummary.value = payload || null
budgetRows.value = allocations.map(normalizeBudgetAllocationRow)
} catch (error) {
budgetError.value = error?.message || 'Failed to load budget data'
budgetSummary.value = null
budgetRows.value = []
console.warn('Failed to load budget data:', error)
} finally {
budgetLoading.value = false
}
@@ -485,24 +324,24 @@ export default {
})
watch(
[
activeDepartmentCode,
budgetPageSize,
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.expenseType,
() => filters.value.status,
budgetTableKeyword
],
() => activeBudgetScope.value,
() => {
filters.value.status = '全部'
budgetPage.value = 1
selectedBudgetId.value = ''
}
)
watch(
[activeDepartmentCode, () => filters.value.year, () => filters.value.quarter],
[
budgetPageSize,
budgetKeyword,
() => filters.value.year,
() => filters.value.quarter,
() => filters.value.status
],
() => {
void loadBudgetData()
budgetPage.value = 1
}
)
@@ -512,37 +351,51 @@ export default {
}
})
watch(detailMode, (value) => {
emit('detail-open-change', value)
}, { immediate: true })
watch(budgetDetailTopBarPayload, (payload) => {
emit('detail-topbar-change', payload)
}, { immediate: true, deep: true })
return {
activeDepartmentCode,
activeDepartmentName,
BUDGET_SCOPE_ALL,
BUDGET_SCOPE_ARCHIVE,
BUDGET_SCOPE_REVIEW,
activeBudgetScope,
budgetError,
budgetKeyword,
budgetLoading,
budgetMetrics,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions,
budgetTableKeyword,
budgetScopeTabs,
backToList,
canAuditBudgetDrafts,
canEditBudget,
canSwitchDepartments,
changeBudgetPage,
departmentKeyword,
departments,
expenseTypes: ['全部', ...EXPENSE_BLUEPRINTS.map((item) => item.expenseType)],
changeBudgetPageSize,
detailMode,
emptyState,
filters,
openBudgetAssistant,
quarters: BUDGET_QUARTER_OPTIONS,
departmentOptions,
statuses: ['全部', '正常', '预警', '管控'],
goToBudgetPage,
handleRowAction,
openBudgetAssistant,
openBudgetDetail,
openBudgetReviewAssistant,
pageSummary,
quarterOptions,
selectedBudget,
selectedBudgetStatusNotes,
selectedBudgetUsageData,
showEmpty,
showTable,
statusOptions,
totalBudgetPages,
totalBudgetRows,
budgetUsageData,
visibleBudgetRows,
visibleDepartments,
warnings,
yearOptions,
years: BUDGET_YEAR_OPTIONS
yearOptions
}
}
}

View File

@@ -169,13 +169,17 @@ export default {
user_id: currentUser.value?.username || currentUser.value?.name || 'anonymous',
context_json: {
role_codes: currentUser.value?.roleCodes || [],
is_admin: Boolean(currentUser.value?.isAdmin),
name: currentUser.value?.name || '',
role: currentUser.value?.role || '',
position: currentUser.value?.position || '',
grade: currentUser.value?.grade || ''
}
})
is_admin: Boolean(currentUser.value?.isAdmin),
name: currentUser.value?.name || '',
role: currentUser.value?.role || '',
department: currentUser.value?.department || currentUser.value?.departmentName || '',
department_name: currentUser.value?.departmentName || currentUser.value?.department || '',
position: currentUser.value?.position || '',
grade: currentUser.value?.grade || '',
employee_no: currentUser.value?.employeeNo || '',
manager_name: currentUser.value?.managerName || currentUser.value?.manager_name || ''
}
})
} catch (error) {
semanticResult.value = null
semanticError.value = error.message || '语义解析失败,请稍后重试。'

View File

@@ -1,4 +1,5 @@
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
import AgentTraceCenterView from '../AgentTraceCenterView.vue'
import LlmSettingsPanel from '../LlmSettingsPanel.vue'
import LogDetailView from '../LogDetailView.vue'
import LogsView from '../LogsView.vue'
@@ -9,6 +10,7 @@ import { useSettings } from '../../composables/useSettings.js'
export default {
name: 'SettingsView',
components: {
AgentTraceCenterView,
HermesEmployeeSettingsPanel,
EnterpriseSelect,
LlmSettingsPanel,

View File

@@ -556,7 +556,7 @@ export default {
emits: ['close', 'draft-saved', 'request-updated'],
setup(props, { emit }) {
const router = useRouter()
const { currentUser } = useSystemState()
const { currentUser, refreshCurrentUserFromBackend } = useSystemState()
const { toast } = useToast()
const fileInputRef = ref(null)
@@ -1067,6 +1067,7 @@ export default {
createMessage,
currentInsight,
currentUser,
refreshCurrentUserFromBackend,
draftClaimId,
extractReviewAttachmentNames,
failCurrentFlowStep,
@@ -1149,6 +1150,7 @@ export default {
lockSuggestedActionMessage,
submitExistingComposer: submitComposerInternal,
currentUser,
refreshCurrentUserFromBackend,
toast
})
function openTravelCalculator() {
@@ -1493,7 +1495,7 @@ export default {
await switchSessionType(shortcut.targetSessionType)
return
}
if (handleGuidedShortcut(shortcut)) {
if (await handleGuidedShortcut(shortcut)) {
return
}

View File

@@ -7,7 +7,7 @@ import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import TravelRequestApprovalDialog from '../../components/travel/TravelRequestApprovalDialog.vue'
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import EmployeeProfileRiskCard from '../../components/travel/EmployeeProfileRiskCard.vue'
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import {
@@ -16,9 +16,9 @@ import {
deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment,
deleteExpenseClaim,
fetchEmployeeLatestProfile,
fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview,
preReviewExpenseClaim,
returnExpenseClaim,
submitExpenseClaim,
uploadExpenseClaimItemAttachment,
@@ -35,6 +35,10 @@ import {
isCurrentRequestApplicant,
isFinanceUser
} from '../../utils/accessControl.js'
import {
buildRiskViewerContext,
filterRiskCardsForVisibility
} from '../../utils/riskVisibility.js'
import {
buildLeaderApprovalEvents,
buildLeaderApprovalInfo,
@@ -52,6 +56,7 @@ import {
buildClaimSummaryRiskCards,
buildItemClaimRiskState,
extractRiskTagsFromText,
filterRiskCardsByBusinessStage,
normalizeRiskTone,
resolveRiskTags
} from './travelRequestDetailInsights.js'
@@ -78,6 +83,17 @@ import {
resolveExpenseReasonPlaceholder,
resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js'
import {
buildAiPreReviewSnapshot,
findLatestAiPreReviewEvent,
isAiPreReviewFlag,
isAiPreReviewPassed,
resolveAiPreReviewToast,
resolveSubmitActionIcon,
resolveSubmitActionLabel,
resolveSubmitConfirmDescription,
resolveSubmitConfirmText
} from './travelRequestDetailPreReviewModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
/*
@@ -377,7 +393,7 @@ export default {
components: {
ConfirmDialog,
EnterpriseSelect,
EmployeeProfileRiskCard,
StageRiskAdviceCard,
RiskObservationEvidenceCard,
TravelRequestApprovalDialog,
TravelRequestBudgetAnalysis,
@@ -410,6 +426,8 @@ export default {
const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('')
const submitBusy = ref(false)
const aiPreReviewSnapshot = ref(null)
const riskFlagPreviewSnapshot = ref(null)
const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false)
@@ -441,10 +459,6 @@ export default {
})
const detailNoteEditor = ref('')
const savingDetailNote = ref(false)
const employeeRiskProfile = ref(null)
const employeeRiskProfileLoading = ref(false)
const employeeRiskProfileError = ref('')
let employeeRiskProfileLoadSeq = 0
const request = computed(() => {
const normalized = normalizeRequestForUi(props.request)
@@ -496,7 +510,10 @@ export default {
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
}
return isEditableRequest.value || canManageCurrentClaim.value
if (canManageCurrentClaim.value) {
return true
}
return isEditableRequest.value && isCurrentApplicant.value
})
const isDirectManagerApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
@@ -533,29 +550,6 @@ export default {
&& canApproveBudgetExpenseApplications(currentUser.value, request.value)
&& !isCurrentApplicant.value
))
const employeeProfileId = computed(() =>
String(
request.value.employeeId
|| request.value.employee_id
|| request.value.profileEmployeeId
|| ''
).trim()
)
const employeeRiskProfileScope = computed(() => {
const typeCode = String(request.value.typeCode || request.value.expense_type || '').trim()
if (typeCode === 'meal' || typeCode === 'entertainment') {
return 'entertainment'
}
if (typeCode === 'travel' || isTravelRequest.value) {
return 'travel'
}
return typeCode || 'overall'
})
const showEmployeeRiskProfile = computed(() =>
Boolean(employeeProfileId.value)
&& Boolean(request.value.claimId)
&& !isDraftRequest.value
)
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
@@ -581,6 +575,25 @@ export default {
|| canProcessBudgetApprovalStage.value
)
)
const canViewApprovalRiskAdvice = computed(() => (
Boolean(request.value.claimId)
&& !isDraftRequest.value
&& !isCurrentApplicant.value
&& (canReturnRequest.value || canApproveRequest.value)
))
const showStageRiskAdvice = computed(() => canViewApprovalRiskAdvice.value)
const riskViewerContext = computed(() => buildRiskViewerContext({
request: request.value,
currentUser: currentUser.value,
businessStage: isApplicationDocument.value ? 'expense_application' : 'reimbursement',
isApplicationDocument: isApplicationDocument.value,
isCurrentApplicant: isCurrentApplicant.value,
isBudgetReviewer: canProcessBudgetApprovalStage.value,
isDirectManagerReviewer: isCurrentDirectManagerApprover.value,
isFinanceReviewer: canProcessFinanceApprovalStage.value,
isAdminViewer: canManageCurrentClaim.value,
canViewApprovalRiskAdvice: canViewApprovalRiskAdvice.value
}))
const {
canPayRequest,
closePayConfirmDialog,
@@ -628,7 +641,7 @@ export default {
if (isBudgetApprovalStage.value) {
return '不填写附加意见则默认同意,确认后会归档申请单并生成报销草稿。'
}
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后会流转至预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
return isApplicationDocument.value ? '不填写附加意见则默认同意,确认后系统会按预算与风险结果决定下一步:无风险且预算充足将直接完成申请,否则进入预算管理者审批。' : '不填写附加意见则默认同意,审批通过后将流转至财务审批。'
})
const approvalConfirmBadge = computed(() => {
if (isFinanceApprovalStage.value) {
@@ -643,7 +656,7 @@ export default {
if (isApplicationDocument.value) {
return isBudgetApprovalStage.value
? '确认后该申请单会完成预算审核,归档申请单,并自动进入申请人的报销草稿中。'
: '确认后该申请单会完成直属领导审批,并流转给预算管理者进一步审核。'
: '确认后该申请单会完成直属领导审批,系统将按预算余额、当前风险和历史风险判断是否需要预算管理者复核;无风险且预算充足会直接完成申请并生成报销草稿。'
}
return '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
})
@@ -666,7 +679,7 @@ export default {
return isApplicationDocument.value
? isBudgetApprovalStage.value
? `${request.value.id} 已完成预算审核,正在生成报销草稿。`
: `${request.value.id} 已确认审核,已流转至预算管理者审批`
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程`
: `${request.value.id} 已审批通过,流转至财务审批。`
})
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
@@ -713,6 +726,7 @@ export default {
Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key]
})
aiPreReviewSnapshot.value = null
closeAttachmentPreview()
}
pendingUploadExpenseId.value = ''
@@ -724,19 +738,6 @@ export default {
{ immediate: true }
)
watch(
() => [
employeeProfileId.value,
request.value.claimId,
employeeRiskProfileScope.value,
showEmployeeRiskProfile.value
],
() => {
void loadEmployeeRiskProfile()
},
{ immediate: true }
)
const heroFactItems = computed(() => [
{
key: 'document',
@@ -846,6 +847,12 @@ export default {
},
{ immediate: true }
)
watch(
() => request.value.claimId,
() => {
riskFlagPreviewSnapshot.value = null
}
)
const draftBlockingIssues = computed(() =>
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
)
@@ -907,7 +914,25 @@ export default {
function resolveClaimRiskFlags() {
const flags = request.value?.riskFlags || request.value?.risk_flags_json || []
return Array.isArray(flags) ? flags : []
let requestFlags = Array.isArray(flags) ? flags : []
const previewSnapshot = riskFlagPreviewSnapshot.value
if (
previewSnapshot
&& previewSnapshot.claimId === request.value?.claimId
&& Array.isArray(previewSnapshot.riskFlags)
) {
requestFlags = previewSnapshot.riskFlags
}
const snapshot = aiPreReviewSnapshot.value
if (
snapshot
&& snapshot.claimId === request.value?.claimId
&& Array.isArray(snapshot.riskFlags)
&& !requestFlags.some(isAiPreReviewFlag)
) {
return snapshot.riskFlags
}
return requestFlags
}
function resolveAttachmentDisplayName(item) {
@@ -953,38 +978,6 @@ export default {
return payload
}
async function loadEmployeeRiskProfile() {
const sequence = ++employeeRiskProfileLoadSeq
employeeRiskProfileError.value = ''
if (!showEmployeeRiskProfile.value) {
employeeRiskProfile.value = null
employeeRiskProfileLoading.value = false
return
}
employeeRiskProfileLoading.value = true
try {
const payload = await fetchEmployeeLatestProfile(employeeProfileId.value, {
scene: 'approval',
claim_id: request.value.claimId,
window_days: 90,
expense_type_scope: employeeRiskProfileScope.value
})
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = payload
}
} catch (error) {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = error?.message || '画像读取失败,请稍后重试。'
}
} finally {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfileLoading.value = false
}
}
}
function canPreviewAttachment(item) {
if (!item?.invoiceId) {
return false
@@ -1100,23 +1093,66 @@ export default {
return summary ? `重大风险警示:${summary}` : '重大风险警示'
}
function applyAiPreReviewPayload(payload) {
aiPreReviewSnapshot.value = buildAiPreReviewSnapshot(payload, request.value.claimId)
}
function applyClaimRiskFlagsPayload(payload) {
const flags = Array.isArray(payload?.claim_risk_flags)
? payload.claim_risk_flags
: Array.isArray(payload?.claimRiskFlags)
? payload.claimRiskFlags
: null
if (!flags) {
return
}
riskFlagPreviewSnapshot.value = {
claimId: request.value.claimId,
riskFlags: flags
}
}
const requiresAiPreReview = computed(() => isEditableRequest.value && !isApplicationDocument.value)
const aiPreReviewEvent = computed(() => findLatestAiPreReviewEvent(resolveClaimRiskFlags()))
const hasAiPreReviewResult = computed(() => !requiresAiPreReview.value || Boolean(aiPreReviewEvent.value))
const aiPreReviewPassed = computed(() =>
isAiPreReviewPassed(aiPreReviewEvent.value, requiresAiPreReview.value)
)
const aiAdvice = computed(() => {
const completionItems = isEditableRequest.value
? draftBlockingIssues.value.map(mapIssueToAdvice).filter(Boolean)
: []
const directRiskCards = buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: resolveClaimRiskFlags()
})
const currentBusinessStage = isApplicationDocument.value ? 'expense_application' : 'reimbursement'
const directRiskCards = filterRiskCardsByBusinessStage(
buildAttachmentRiskCards({
expenseItems: expenseItems.value,
attachmentMetaByItemId: expenseAttachmentMeta,
claimRiskFlags: resolveClaimRiskFlags(),
businessStage: currentBusinessStage
}),
currentBusinessStage
)
const hasActionableRiskCards = directRiskCards.some(
(card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))
)
const riskCards = [
...(hasActionableRiskCards ? [] : buildClaimSummaryRiskCards(request.value)),
const summaryRiskCards = filterRiskCardsByBusinessStage(
buildClaimSummaryRiskCards({
...(request.value || {}),
businessStage: currentBusinessStage
}),
currentBusinessStage
)
const optionalRiskCards = filterRiskCardsByBusinessStage(
buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value),
currentBusinessStage
)
const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards,
...buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value)
...optionalRiskCards
]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
return buildAiAdviceViewModel({
completionItems,
@@ -1124,13 +1160,54 @@ export default {
})
})
const showAiAdvicePanel = computed(() => isEditableRequest.value || aiAdvice.value.riskCards.length > 0)
const aiAdviceTitle = computed(() => (isEditableRequest.value ? 'AI建议' : 'AI提示'))
const aiAdviceHint = computed(() => (
isEditableRequest.value
? '按建议顺序补齐信息或处理风险后,再发起审批。'
: '展示系统已识别的风险点,便于审批和后续整改。'
const hasVisibleRiskCards = computed(() =>
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
)
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
const showAiAdvicePanel = computed(() => (
(
isEditableRequest.value
&& (
(requiresAiPreReview.value && hasAiPreReviewResult.value)
|| hasAdviceSections.value
)
)
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
|| (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value && hasVisibleRiskCards.value)
))
const aiAdviceTitle = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
return '报销风险提示'
}
if (isEditableRequest.value && isApplicationDocument.value) {
return '表单自查提示'
}
return isEditableRequest.value ? 'AI建议' : 'AI提示'
})
const aiAdviceHint = computed(() => (
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
: isEditableRequest.value
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : 'AI预审已完成请按风险提示补充原因或进入下一步。')
: '展示系统已识别的风险点,便于审批和后续整改。'
))
const submitActionLabel = computed(() => {
return resolveSubmitActionLabel({
isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value,
submitBusy: submitBusy.value
})
})
const submitActionIcon = computed(() => resolveSubmitActionIcon({
isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value
}))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value,
aiPreReviewPassed: aiPreReviewPassed.value
}))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards
@@ -1470,6 +1547,7 @@ export default {
try {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
applyClaimRiskFlagsPayload(payload)
expenseAttachmentMeta[item.id] = payload?.attachment || null
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const recognizedItemDate = normalizeIsoDateValue(payload?.item_date ?? payload?.itemDate)
@@ -1519,6 +1597,7 @@ export default {
deletingAttachmentId.value = item.id
try {
const payload = await deleteExpenseClaimItemAttachment(request.value.claimId, item.id)
applyClaimRiskFlagsPayload(payload)
delete expenseAttachmentMeta[item.id]
applyLocalExpenseItemPatch(item.id, {
invoiceId: '',
@@ -1672,7 +1751,22 @@ export default {
}
}
function handleSubmit() {
async function runAiPreReview() {
submitBusy.value = true
try {
const payload = await preReviewExpenseClaim(request.value.claimId)
applyAiPreReviewPayload(payload)
const event = findLatestAiPreReviewEvent(payload?.risk_flags_json || [])
toast(resolveAiPreReviewToast(event))
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || 'AI预审失败请稍后重试。')
} finally {
submitBusy.value = false
}
}
async function handleSubmit() {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。')
return
@@ -1688,6 +1782,11 @@ export default {
return
}
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
openRiskOverrideDialog()
return
@@ -1723,6 +1822,12 @@ export default {
return
}
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
submitConfirmDialogOpen.value = false
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
submitConfirmDialogOpen.value = false
openRiskOverrideDialog()
@@ -1862,6 +1967,14 @@ export default {
approveConfirmDialogOpen.value = false
}
function resolveApproveErrorMessage(error) {
const message = String(error?.message || '').trim()
if (message.includes('未找到同部门 P8 预算审批人')) {
return '当前部门未配置 P8 预算审批人,请联系管理员配置后再审批。'
}
return message || '审批通过失败,请稍后重试。'
}
async function confirmApproveRequest() {
if (!request.value.claimId) {
toast('当前单据缺少 claimId暂时无法审批通过。')
@@ -1889,8 +2002,9 @@ export default {
: approvalSuccessToast.value
)
emit('request-updated', { claimId: request.value.claimId })
emit('backToRequests')
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
toast(resolveApproveErrorMessage(error))
} finally {
approveBusy.value = false
}
@@ -1939,7 +2053,6 @@ export default {
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor,
employeeRiskProfile, employeeRiskProfileError, employeeRiskProfileLoading,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
@@ -1957,9 +2070,9 @@ export default {
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showEmployeeRiskProfile,
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}
}

View File

@@ -8,6 +8,16 @@ function resolveRiskScoreCardColor(level) {
return 'var(--theme-primary)'
}
function resolveStateColor(tone, fallback = 'var(--theme-primary)') {
const normalized = normalizeText(tone).toLowerCase()
if (['active', 'success', 'online'].includes(normalized)) return 'var(--success)'
if (['disabled', 'offline', 'draft'].includes(normalized)) return '#64748b'
if (['failed', 'danger', 'critical', 'high'].includes(normalized)) return '#ef4444'
if (['review', 'warning', 'medium'].includes(normalized)) return '#f59e0b'
if (['generating', 'info'].includes(normalized)) return 'var(--theme-primary)'
return fallback
}
export function buildAuditDetailTopBar({
skill,
usesJsonRiskRule = false
@@ -35,6 +45,38 @@ export function buildAuditDetailTopBar({
: 'up',
color: resolveRiskScoreCardColor(scoreLevel)
})
kpis.push({
label: '风险等级',
value: normalizeText(skill.riskRuleSeverityLabel) || '待评估',
unit: '',
meta: normalizeText(skill.riskRuleScoreLabel) || '评分模型',
trend: ['critical', 'high', 'medium'].includes(normalizeText(scoreLevel).toLowerCase()) ? 'down' : 'up',
color: resolveRiskScoreCardColor(scoreLevel)
})
kpis.push({
label: '规则状态',
value: normalizeText(skill.status) || '待上线',
unit: '',
meta: normalizeText(skill.displayVersion) || '工作版本',
trend: '',
color: resolveStateColor(skill.statusTone)
})
kpis.push({
label: '上线状态',
value: normalizeText(skill.isOnlineLabel) || '待上线',
unit: '',
meta: normalizeText(skill.publishedAt) && skill.publishedAt !== '-' ? skill.publishedAt : '未发布',
trend: skill.isOnlineValue ? 'up' : '',
color: resolveStateColor(skill.isOnlineTone)
})
kpis.push({
label: '启用状态',
value: normalizeText(skill.isEnabledLabel) || '否',
unit: '',
meta: skill.isEnabledValue ? '参与扫描' : '不参与扫描',
trend: skill.isEnabledValue ? 'up' : '',
color: resolveStateColor(skill.isEnabledTone)
})
}
return {

View File

@@ -209,6 +209,7 @@ export const RULE_TAB_TAG_ALIASES = {
export const RISK_SCENARIO_OPTIONS = [
{ value: '', label: '全部场景' },
{ value: '全部', label: '全部' },
{ value: '差旅费', label: '差旅费' },
{ value: '住宿费', label: '住宿费' },
{ value: '交通费', label: '交通费' },

View File

@@ -5,15 +5,11 @@ export const RISK_RULE_CREATE_DOMAIN_OPTIONS = [
]
export const RISK_RULE_EXPENSE_CATEGORY_OPTIONS = [
{ value: 'all', label: '全部' },
{ value: 'travel', label: '差旅费' },
{ value: 'hotel', label: '住宿费' },
{ value: 'transport', label: '交通费' },
{ value: 'meal', label: '业务招待费' },
{ value: 'meeting', label: '会务费' },
{ value: 'office', label: '办公用品费' },
{ value: 'training', label: '培训费' },
{ value: 'communication', label: '通讯费' },
{ value: 'welfare', label: '福利费' }
{ value: 'communication', label: '通信费' }
]
export const RISK_RULE_BUSINESS_STAGE_OPTIONS = [
@@ -55,7 +51,7 @@ export function createDefaultRiskRuleForm() {
return {
business_domain: 'expense',
business_stage: 'reimbursement',
expense_category: 'travel',
expense_category: 'all',
rule_title: '',
requires_attachment: false,
natural_language: ''

View File

@@ -42,6 +42,7 @@ const LAST_OPERATION_LABELS = {
test: '测试',
online: '上线',
offline: '下线',
generation_failed: '生成失败',
delete: '删除',
update: '更新'
}

View File

@@ -14,6 +14,7 @@ import {
} from './auditViewDataUtils.js'
import { formatScenarioList } from './auditViewFormatters.js'
const EXPENSE_TYPE_SCENARIO_LABELS = {
all: '全部',
travel: '差旅费',
hotel: '住宿费',
transport: '交通费',
@@ -142,6 +143,10 @@ export function normalizeRiskScenarioCategory(value) {
export function normalizeExpenseTypeScenarioLabels(value) {
const values = Array.isArray(value) ? value : normalizeText(value) ? [value] : []
if (values.some((item) => ['all', '*', 'overall', 'general', '全部', '通用'].includes(normalizeText(item).toLowerCase()))) {
return ['全部']
}
const labels = []
const seen = new Set()

View File

@@ -105,6 +105,10 @@ function parseYear(rawText) {
return match ? Number(match[1]) : 2026
}
function hasExplicitYear(rawText) {
return /(20\d{2})/.test(String(rawText || ''))
}
function resolvePreviousPeriod(year, quarter) {
if (quarter > 1) {
return { year, quarter: quarter - 1 }
@@ -117,35 +121,52 @@ export function shouldUseBudgetCompileReport(rawText, options = {}) {
return false
}
const text = normalizeBudgetText(rawText)
const hasTargetPeriod = parseQuarter(rawText) || hasExplicitYear(rawText)
return Boolean(
text &&
/(预算|budget)/.test(text) &&
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) &&
parseQuarter(rawText)
hasTargetPeriod
)
}
export function buildBudgetCompileReport(rawText, user = {}) {
const targetYear = parseYear(rawText)
const targetQuarter = parseQuarter(rawText) || 3
const previous = resolvePreviousPeriod(targetYear, targetQuarter)
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value, 0)
const totalBudget = 1320000
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget, 0)
const parsedQuarter = parseQuarter(rawText)
const isAnnualBudget = !parsedQuarter
const targetQuarter = parsedQuarter || 1
const previous = isAnnualBudget
? { year: targetYear - 1, quarter: 0 }
: resolvePreviousPeriod(targetYear, targetQuarter)
const periodMultiplier = isAnnualBudget ? 4 : 1
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value * periodMultiplier, 0)
const totalBudget = 1320000 * periodMultiplier
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget * periodMultiplier, 0)
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门'
const items = PREVIOUS_QUARTER_SPEND.map((item) => {
const value = item.value * periodMultiplier
const previousValue = item.previousValue * periodMultiplier
const recommendedBudget = item.recommendedBudget * periodMultiplier
const trendValue = item.previousValue
? ((item.value - item.previousValue) / item.previousValue) * 100
? ((value - previousValue) / previousValue) * 100
: 0
return {
...item,
amountDisplay: compactCurrency(item.value),
display: percent(item.value, totalSpend),
share: percent(item.value, totalSpend),
value,
previousValue,
recommendedBudget,
amountDisplay: compactCurrency(value),
display: percent(value, totalSpend),
share: percent(value, totalSpend),
trend: `${trendValue >= 0 ? '+' : ''}${trendValue.toFixed(1)}%`,
trendTone: trendValue >= 10 ? 'risk' : trendValue >= 0 ? 'warn' : 'stable',
recommendedDisplay: compactCurrency(item.recommendedBudget)
recommendedDisplay: compactCurrency(recommendedBudget),
editableBudget: recommendedBudget,
reminderThreshold: item.key === 'communication' || item.key === 'office' ? 60 : 70,
alertThreshold: item.key === 'communication' || item.key === 'office' ? 70 : 80,
riskThreshold: item.key === 'communication' || item.key === 'office' ? 80 : 90,
editNote: item.suggestion
}
})
@@ -158,13 +179,18 @@ export function buildBudgetCompileReport(rawText, user = {}) {
return {
type: 'budget_compile_analysis',
title: `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: `基于${previous.year}${previous.quarter}度预算执行模拟数据`,
title: isAnnualBudget
? `${targetYear}度预算编制前置分析报告`
: `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: isAnnualBudget
? `基于${previous.year}年度预算执行模拟数据`
: `基于${previous.year}${previous.quarter}季度预算执行模拟数据`,
departmentName,
targetPeriod: `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`,
basePeriod: `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`,
targetPeriod: isAnnualBudget ? `${targetYear}年度` : `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`,
basePeriod: isAnnualBudget ? `${previous.year}年度` : `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`,
periodType: isAnnualBudget ? '年度预算' : '季度预算',
centerValue: compactCurrency(totalSpend),
centerLabel: '上季度开销',
centerLabel: isAnnualBudget ? '去年开销' : '上季度开销',
summary: {
totalBudget: compactCurrency(totalBudget),
totalSpend: compactCurrency(totalSpend),
@@ -172,13 +198,25 @@ export function buildBudgetCompileReport(rawText, user = {}) {
recommendedTotal: compactCurrency(recommendedTotal)
},
macroInsights: [
`${previous.year}${previous.quarter}季度实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${targetYear}${targetQuarter}季度预算编制的第一优先级。`,
`${isAnnualBudget ? `${previous.year}年度` : `${previous.year}${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}预算编制的第一优先级。`,
`${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
],
items,
editableDraft: {
status: 'editing',
rows: items.map((item) => ({
key: item.key,
name: item.name,
budgetAmount: item.editableBudget,
reminderThreshold: item.reminderThreshold,
alertThreshold: item.alertThreshold,
riskThreshold: item.riskThreshold,
note: item.editNote
}))
},
recommendations: [
`建议${targetYear}${targetQuarter}季度总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
],

View File

@@ -0,0 +1,365 @@
import {
BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS,
formatBudgetPeriod
} from '../../utils/budgetOntology.js'
export const BUDGET_SCOPE_ALL = 'all'
export const BUDGET_SCOPE_REVIEW = 'review'
export const BUDGET_SCOPE_ARCHIVE = 'archive'
export const BUDGET_SCOPE_TABS = Object.freeze([
{ value: BUDGET_SCOPE_ALL, label: '全部预算' },
{ value: BUDGET_SCOPE_REVIEW, label: '预算审核' },
{ value: BUDGET_SCOPE_ARCHIVE, label: '归档预算' }
])
export const BUDGET_PAGE_SIZE_OPTIONS = Object.freeze([8, 12, 20])
const STATUS_OPTIONS_BY_SCOPE = Object.freeze({
[BUDGET_SCOPE_ALL]: ['全部', '正常', '预警', '管控'],
[BUDGET_SCOPE_REVIEW]: ['全部', '待审核', '复核中', '待补充', '已驳回'],
[BUDGET_SCOPE_ARCHIVE]: ['全部', '已归档', '已替换', '已驳回']
})
const DEPARTMENT_PROFILE = Object.freeze({
'MARKET-DEPT': { factor: 1.22, owner: '周明悦', reviewer: '陈思远', riskShift: 12 },
'FINANCE-DEPT': { factor: 0.74, owner: '韩清', reviewer: '沈知行', riskShift: -6 },
'TECH-DEPT': { factor: 1.08, owner: '林子昂', reviewer: '陈思远', riskShift: 4 },
'HR-DEPT': { factor: 0.68, owner: '许婉', reviewer: '沈知行', riskShift: 2 },
'PRODUCTION-DEPT': { factor: 1.36, owner: '赵屿', reviewer: '陈思远', riskShift: 8 },
'PRESIDENT-OFFICE': { factor: 0.92, owner: '孟澜', reviewer: '沈知行', riskShift: -2 }
})
const DEFAULT_PROFILE = Object.freeze({
factor: 1,
owner: '预算编制助手',
reviewer: '高级财务人员',
riskShift: 0
})
const CATEGORY_SEED = Object.freeze({
travel: { total: 600000, used: 242300, occupied: 150000, warning: 80 },
communication: { total: 120000, used: 38600, occupied: 18000, warning: 70 },
meal: { total: 420000, used: 168200, occupied: 118000, warning: 80 },
office: { total: 180000, used: 68500, occupied: 32000, warning: 70 }
})
const QUARTER_FACTOR = Object.freeze({
Q1: 0.92,
Q2: 1,
Q3: 1.12,
Q4: 1.18
})
const REVIEW_STATUS_SEQUENCE = ['待审核', '待审核', '复核中', '待补充', '待审核', '已驳回']
const ARCHIVE_STATUS_SEQUENCE = ['已归档', '已替换', '已驳回', '已归档', '已替换', '已归档']
export function currency(value) {
return Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})
}
export function money(value) {
return `¥${currency(value)}`
}
export function getBudgetStatusOptions(scope) {
return STATUS_OPTIONS_BY_SCOPE[scope] || STATUS_OPTIONS_BY_SCOPE[BUDGET_SCOPE_ALL]
}
export function buildBudgetScopeTabs(rowsByScope) {
return BUDGET_SCOPE_TABS.map((tab) => ({
...tab,
count: Array.isArray(rowsByScope?.[tab.value]) ? rowsByScope[tab.value].length : 0
}))
}
export function buildBudgetRows({ departments = [], year = '2026', quarter = 'Q1' } = {}) {
const scopedDepartments = Array.isArray(departments) ? departments : []
return {
[BUDGET_SCOPE_ALL]: scopedDepartments.map((department, index) =>
buildActiveBudgetRow(department, index, { year, quarter })
),
[BUDGET_SCOPE_REVIEW]: scopedDepartments.map((department, index) =>
buildReviewBudgetRow(department, index, { year, quarter })
),
[BUDGET_SCOPE_ARCHIVE]: scopedDepartments.map((department, index) =>
buildArchiveBudgetRow(department, index, { year, quarter })
)
}
}
export function buildBudgetUsageData(row) {
const categories = Array.isArray(row?.categoryRows) ? row.categoryRows : []
return {
labels: categories.map((item) => item.name),
budget: categories.map((item) => item.amount),
used: categories.map((item) => item.used),
occupied: categories.map((item) => item.occupied),
available: categories.map((item) => item.available)
}
}
export function matchesBudgetKeyword(row, keyword) {
const normalized = String(keyword || '').trim().toLowerCase()
if (!normalized) return true
return String(row?.searchText || '').includes(normalized)
}
function buildActiveBudgetRow(department, index, context) {
const profile = resolveProfile(department)
const categoryRows = buildCategoryRows(department, index, context)
const totals = summarizeCategories(categoryRows)
const risk = resolveRisk(totals.usageRate)
const periodLabel = formatBudgetPeriod(context.year, context.quarter)
const budgetNo = `BUD-${context.year}-${department.code || index + 1}`
return enrichSearchText({
id: `${budgetNo}-ACTIVE`,
scope: BUDGET_SCOPE_ALL,
budgetNo,
departmentCode: department.code || '',
departmentName: department.name || '当前部门',
costCenter: department.costCenter || '',
periodLabel,
periodType: '季度预算',
budgetYear: `${context.year}年度`,
budgetQuarter: context.quarter,
version: `V${index + 1}.0`,
owner: profile.owner,
reviewer: profile.reviewer,
annualAmount: totals.annualAmount,
quarterAmount: totals.total,
monthAmount: totals.total / 3,
usedAmount: totals.used,
occupiedAmount: totals.occupied,
availableAmount: totals.available,
annualAmountLabel: money(totals.annualAmount),
quarterAmountLabel: money(totals.total),
monthAmountLabel: money(totals.total / 3),
usedAmountLabel: money(totals.used),
occupiedAmountLabel: money(totals.occupied),
availableAmountLabel: money(totals.available),
usageRate: totals.usageRate,
usageRateLabel: `${totals.usageRate}%`,
riskTone: risk.tone,
riskLabel: risk.label,
statusLabel: risk.tone === 'risk' ? '管控' : risk.tone === 'alert' ? '预警' : '正常',
statusTone: risk.tone,
updatedAt: `2026-05-${String(28 - index).padStart(2, '0')} 16:${String(20 + index).padStart(2, '0')}`,
categoryRows,
periodRows: buildPeriodRows(totals),
auditSummary: '已通过高级财务审核并发布为正式预算。',
actionLabel: '查看详情'
})
}
function buildReviewBudgetRow(department, index, context) {
const activeRow = buildActiveBudgetRow(department, index, context)
const profile = resolveProfile(department)
const statusLabel = REVIEW_STATUS_SEQUENCE[index % REVIEW_STATUS_SEQUENCE.length]
const applyFactor = 1 + (index % 3) * 0.06 + Math.max(profile.riskShift, 0) / 200
const requestedAmount = activeRow.quarterAmount * applyFactor
const changeRate = Number(((requestedAmount / activeRow.quarterAmount - 1) * 100).toFixed(1))
const risk = resolveRisk(activeRow.usageRate + profile.riskShift)
const categoryRows = activeRow.categoryRows.map((item) => ({
...item,
amount: Math.round(item.amount * applyFactor),
amountLabel: money(Math.round(item.amount * applyFactor)),
note: buildReviewNote(item.name, risk.tone)
}))
return enrichSearchText({
...activeRow,
id: `${activeRow.budgetNo}-DRAFT`,
scope: BUDGET_SCOPE_REVIEW,
budgetNo: `DRF-${context.year}-${department.code || index + 1}`,
periodType: '预算草案',
version: `草案 V1.${index}`,
compiler: profile.owner,
submittedAt: `2026-05-${String(26 + (index % 3)).padStart(2, '0')} ${String(10 + index).padStart(2, '0')}:20`,
requestedAmount,
requestedAmountLabel: money(requestedAmount),
previousAmountLabel: activeRow.quarterAmountLabel,
changeRate,
changeRateLabel: `${changeRate >= 0 ? '+' : ''}${changeRate}%`,
aiScore: Math.max(68, Math.min(94, 88 - index * 2 - Math.max(profile.riskShift, 0))),
riskTone: risk.tone,
riskLabel: risk.label,
statusLabel,
statusTone: resolveReviewStatusTone(statusLabel),
categoryRows,
periodRows: buildPeriodRows({
total: requestedAmount,
annualAmount: requestedAmount * 4,
used: activeRow.usedAmount,
occupied: activeRow.occupiedAmount,
available: Math.max(requestedAmount - activeRow.usedAmount - activeRow.occupiedAmount, 0),
usageRate: percent(activeRow.usedAmount + activeRow.occupiedAmount, requestedAmount)
}),
auditSummary: '等待高级财务人员审核,审核通过后才能发布到正式预算中心。',
actionLabel: '进入审核'
})
}
function buildArchiveBudgetRow(department, index, context) {
const activeRow = buildActiveBudgetRow(department, index, context)
const statusLabel = ARCHIVE_STATUS_SEQUENCE[index % ARCHIVE_STATUS_SEQUENCE.length]
const archiveFactor = statusLabel === '已驳回' ? 0.96 : 0.9
return enrichSearchText({
...activeRow,
id: `${activeRow.budgetNo}-ARCHIVE`,
scope: BUDGET_SCOPE_ARCHIVE,
budgetNo: `ARC-${context.year}-${department.code || index + 1}`,
version: `历史 V${Math.max(1, index)}.${index % 3}`,
periodType: '历史预算',
archiveType: statusLabel === '已驳回' ? '审核驳回' : statusLabel === '已替换' ? '版本替换' : '周期归档',
quarterAmount: activeRow.quarterAmount * archiveFactor,
quarterAmountLabel: money(activeRow.quarterAmount * archiveFactor),
reviewer: resolveProfile(department).reviewer,
archivedAt: `2026-05-${String(12 + index).padStart(2, '0')} 18:00`,
statusLabel,
statusTone: statusLabel === '已驳回' ? 'risk' : 'archived',
auditSummary: statusLabel === '已驳回'
? '该预算版本未通过审核,已保留驳回记录。'
: '该预算版本已进入历史归档,可用于审计追溯。',
actionLabel: '查看归档'
})
}
function buildCategoryRows(department, index, context) {
const profile = resolveProfile(department)
const quarterFactor = QUARTER_FACTOR[context.quarter] || 1
const usageShift = 1 + profile.riskShift / 100
return BUDGET_VISIBLE_EXPENSE_TYPE_OPTIONS.map((option, categoryIndex) => {
const seed = CATEGORY_SEED[option.value] || { total: 100000, used: 0, occupied: 0, warning: 70 }
const amount = Math.round(seed.total * profile.factor * quarterFactor * (1 + categoryIndex * 0.015))
const used = Math.round(seed.used * profile.factor * usageShift)
const occupied = Math.round(seed.occupied * profile.factor * Math.max(0.8, usageShift))
const available = Math.max(amount - used - occupied, 0)
const usageRate = percent(used + occupied, amount)
const thresholds = buildThresholds(seed.warning)
const risk = resolveRisk(usageRate, thresholds)
return {
code: option.value,
name: option.label,
amount,
used,
occupied,
available,
amountLabel: money(amount),
usedLabel: money(used),
occupiedLabel: money(occupied),
availableLabel: money(available),
usageRate,
usageRateLabel: `${usageRate}%`,
reminderLine: `${thresholds.reminder}%`,
alertLine: `${thresholds.alert}%`,
riskLine: `${thresholds.risk}%`,
riskTone: risk.tone,
riskLabel: risk.label,
note: buildCategoryNote(option.label, risk.tone, index)
}
})
}
function buildPeriodRows(totals) {
return [
{ label: '年度预算', value: money(totals.annualAmount), desc: '按四类费用预算汇总' },
{ label: '季度预算', value: money(totals.total), desc: '当前列表筛选周期' },
{ label: '月度预算', value: money(totals.total / 3), desc: '按季度预算月均拆分' }
]
}
function summarizeCategories(rows) {
const total = rows.reduce((sum, item) => sum + item.amount, 0)
const used = rows.reduce((sum, item) => sum + item.used, 0)
const occupied = rows.reduce((sum, item) => sum + item.occupied, 0)
const available = Math.max(total - used - occupied, 0)
return {
total,
annualAmount: total * 4,
used,
occupied,
available,
usageRate: percent(used + occupied, total)
}
}
function resolveProfile(department) {
return {
...DEFAULT_PROFILE,
...(DEPARTMENT_PROFILE[department?.code] || {})
}
}
function percent(value, total) {
if (!Number(total)) return 0
return Number(((Number(value || 0) / Number(total)) * 100).toFixed(1))
}
function buildThresholds(warning) {
const alert = clampPercent(warning)
return {
reminder: clampPercent(alert - 10),
alert,
risk: clampPercent(alert + 10)
}
}
function clampPercent(value) {
return Math.min(100, Math.max(0, Number(value) || 0))
}
function resolveRisk(value, thresholds = { reminder: 70, alert: 80, risk: 90 }) {
const rate = Number(value || 0)
if (rate >= thresholds.risk) return { label: '风险', tone: 'risk' }
if (rate >= thresholds.alert) return { label: '告警', tone: 'alert' }
if (rate >= thresholds.reminder) return { label: '提醒', tone: 'reminder' }
return { label: '正常', tone: 'ok' }
}
function resolveReviewStatusTone(status) {
if (status === '待补充') return 'alert'
if (status === '已驳回') return 'risk'
if (status === '复核中') return 'reminder'
return 'pending'
}
function buildCategoryNote(name, tone, index) {
if (tone === 'risk') return `${name}使用率已接近风险线,需要重点复核。`
if (tone === 'alert') return `${name}超过告警线,建议核对业务计划和已占用金额。`
if (tone === 'reminder') return `${name}接近提醒线,后续应持续观察。`
return index % 2 === 0 ? `${name}预算执行稳定。` : `${name}仍在正常预算区间。`
}
function buildReviewNote(name, tone) {
if (tone === 'risk') return `${name}预算增幅较高,审核时需要补充业务依据。`
if (tone === 'alert') return `${name}建议结合上一季度发生额复核。`
return `${name}建议按部门编制说明核对。`
}
function enrichSearchText(row) {
const values = [
row.budgetNo,
row.departmentName,
row.costCenter,
row.periodLabel,
row.periodType,
row.version,
row.owner,
row.compiler,
row.reviewer,
row.statusLabel,
row.riskLabel,
row.archiveType
]
return {
...row,
searchText: values.filter(Boolean).join(' ').toLowerCase()
}
}

View File

@@ -21,6 +21,7 @@ const KNOWLEDGE_JOB_TYPES = new Set([
const TASK_TYPE_LABELS = {
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
risk_clue_collect: '风险线索归集',
finance_policy_knowledge_organize: '知识制度整理',
knowledge_index_sync: '知识制度整理',
llm_wiki_sync: '知识制度整理',
@@ -30,6 +31,7 @@ const TASK_TYPE_LABELS = {
const TASK_CODE_TO_TYPE = {
'task.hermes.global_risk_scan': 'global_risk_scan',
'task.hermes.employee_behavior_profile_scan': 'employee_behavior_profile_scan',
'task.hermes.risk_rule_discovery': 'risk_clue_collect',
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize'
}
@@ -56,6 +58,9 @@ function resolveTaskTypeFromToolName(value) {
if (name.includes('finance_policy_knowledge')) {
return 'finance_policy_knowledge_organize'
}
if (name.includes('risk_clue')) {
return 'risk_clue_collect'
}
return ''
}
@@ -136,6 +141,9 @@ export function resolveWorkRecordProductKind(run) {
if (taskType === 'employee_behavior_profile_scan') {
return 'employee_profile'
}
if (taskType === 'risk_clue_collect') {
return 'risk_clue'
}
if (KNOWLEDGE_JOB_TYPES.has(taskType)) {
return 'knowledge'
}

View File

@@ -158,6 +158,8 @@ export function filterDigitalEmployees(items = [], filters = {}) {
const searchText = normalizeText(filters.keyword).toLowerCase()
const hasKeyword = Boolean(searchText)
const hasStatus = Boolean(filters.selectedStatus)
const selectedSkillCategory = normalizeText(filters.selectedSkillCategory)
const hasSkillCategory = Boolean(selectedSkillCategory)
const hasEnabled = Boolean(filters.selectedEnabledState)
const hasExecutionMode = Boolean(filters.selectedExecutionMode)
@@ -168,6 +170,9 @@ export function filterDigitalEmployees(items = [], filters = {}) {
if (hasStatus && item.statusValue !== filters.selectedStatus) {
return false
}
if (hasSkillCategory && normalizeText(item.skillCategory) !== selectedSkillCategory) {
return false
}
if (hasEnabled && (filters.selectedEnabledState === 'enabled') !== Boolean(item.isEnabledValue)) {
return false
}

View File

@@ -0,0 +1,131 @@
export const emptyDigitalEmployeeDashboard = {
windowDays: 7,
generatedAt: '',
hasRealData: false,
totals: {
totalRuns: 0,
successRuns: 0,
failedRuns: 0,
runningRuns: 0,
toolCalls: 0,
businessOutputs: 0,
riskObservations: 0,
riskClues: 0,
profileSnapshots: 0,
knowledgeDocuments: 0,
successRate: 0,
failureRate: 0
},
dailyWork: [],
taskDistribution: [],
categoryDistribution: [
{ name: '积累', value: 0, count: 0, color: 'var(--chart-blue)', description: '沉淀画像、基线和反馈样本' },
{ name: '升级', value: 0, count: 0, color: 'var(--chart-amber)', description: '输出待复核线索和优化建议' },
{ name: '整理', value: 0, count: 0, color: 'var(--success)', description: '整理制度、条款、知识和样本' },
{ name: '评估', value: 0, count: 0, color: 'var(--theme-primary)', description: '评估异常、风险和一致性' }
],
recentRuns: []
}
export function buildDigitalEmployeeKpiMetrics(dashboard, formatNumberCompact) {
const data = dashboard || emptyDigitalEmployeeDashboard
const totals = data.totals || emptyDigitalEmployeeDashboard.totals
const rows = [
{
label: '工作总数',
value: formatNumberCompact(totals.totalRuns),
changeText: `${data.windowDays || 7}`,
delta: '后台任务',
trend: 'up',
icon: 'mdi mdi-briefcase-clock-outline',
accent: 'var(--theme-primary)'
},
{
label: '成功数量',
value: formatNumberCompact(totals.successRuns),
changeText: `${Number(totals.successRate || 0).toFixed(1)}%`,
delta: '运行成功率',
trend: 'up',
icon: 'mdi mdi-check-decagram-outline',
accent: 'var(--success)'
},
{
label: '失败数量',
value: formatNumberCompact(totals.failedRuns),
changeText: `${Number(totals.failureRate || 0).toFixed(1)}%`,
delta: '需排查',
trend: Number(totals.failedRuns || 0) > 0 ? 'down' : 'up',
icon: 'mdi mdi-alert-circle-outline',
accent: '#ef4444'
},
{
label: '业务产出',
value: formatNumberCompact(totals.businessOutputs),
changeText: '累计',
delta: '观察/线索/快照/文档',
trend: 'up',
icon: 'mdi mdi-chart-box-outline',
accent: '#0f766e'
},
{
label: '工具调用',
value: formatNumberCompact(totals.toolCalls),
changeText: '执行链',
delta: '工具执行次数',
trend: 'up',
icon: 'mdi mdi-tools',
accent: '#2563eb'
},
{
label: '运行中',
value: formatNumberCompact(totals.runningRuns),
changeText: Number(totals.runningRuns || 0) > 0 ? '进行中' : '空闲',
delta: '当前窗口',
trend: Number(totals.runningRuns || 0) > 0 ? 'down' : 'up',
icon: 'mdi mdi-progress-clock',
accent: '#f59e0b'
}
]
return rows.map((item, index) => ({
...item,
displayValue: item.value,
delay: index * 55
}))
}
export function buildDigitalEmployeeDailyRows(dashboard) {
const rows = Array.isArray(dashboard?.dailyWork) ? dashboard.dailyWork : []
return rows.map((item) => ({
date: String(item.date || '').trim() || '-',
total: Number(item.total || 0),
success: Number(item.success || 0),
failed: Number(item.failed || 0),
running: Number(item.running || 0),
riskObservations: Number(item.riskObservations || 0),
riskClues: Number(item.riskClues || 0),
profileSnapshots: Number(item.profileSnapshots || 0),
knowledgeDocuments: Number(item.knowledgeDocuments || 0),
businessOutputs: Number(item.businessOutputs || 0)
}))
}
export function buildDigitalEmployeeTaskRanking(dashboard) {
return (dashboard?.taskDistribution || [])
.slice(0, 6)
.map((item) => ({
name: item.name,
shortName: item.name,
value: Number(item.value || item.count || 0),
color: item.color || 'var(--theme-primary)'
}))
}
export function buildDigitalEmployeeCategoryRows(dashboard) {
return (dashboard?.categoryDistribution || [])
.map((item) => ({
...item,
value: Number(item.value || item.count || 0),
count: Number(item.count || item.value || 0)
}))
}

View File

@@ -0,0 +1,124 @@
import { computed, ref } from 'vue'
export function createReceiptDetailDashboardModel({
detailForm,
editableOtherFields,
formatDateTime,
formatScore,
selectedReceipt
}) {
const previewZoom = ref(1)
const previewRotation = ref(0)
const previewTransform = computed(() => `scale(${previewZoom.value}) rotate(${previewRotation.value}deg)`)
const previewPageLabel = computed(() => {
const pageCount = Number(selectedReceipt.value?.page_count || 1)
return `1 / ${Number.isFinite(pageCount) && pageCount > 0 ? pageCount : 1}`
})
const ocrPreviewFields = computed(() => (
editableOtherFields.value
.filter((field) => String(field?.label || field?.key || field?.value || '').trim())
.slice(0, 6)
))
const basicInfoItems = computed(() => [
{ label: '票据类型', value: fallback(detailForm.document_type_label) },
{ label: '票据名称', value: fallback(detailForm.file_name) },
{ label: '提交人', value: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '当前用户') },
{ label: '上传时间', value: formatDateTime(selectedReceipt.value?.uploaded_at) },
{ label: '所属单据编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
{ label: 'OCR 置信度', value: formatScore(selectedReceipt.value?.avg_score) }
])
const receiptStatusItems = computed(() => {
const linked = selectedReceipt.value?.status === 'linked'
return [
{ label: '识别状态', value: '识别成功', tone: 'success' },
{ label: '关联状态', value: selectedReceipt.value?.status_label || (linked ? '已关联' : '未关联'), tone: linked ? 'success' : 'warning' },
{ label: '重复报销风险', value: '无风险', tone: 'success' },
{ label: '归档状态', value: linked ? '待归档' : '未归档', tone: 'info' }
]
})
const linkedClaimItems = computed(() => [
{ label: '报销单编号', value: fallback(selectedReceipt.value?.linked_claim_no, '未关联') },
{ label: '报销单名称', value: linkedClaimName.value },
{ label: '费用类型', value: fallback(detailForm.scene_label) },
{ label: '申请日期', value: dateOnly(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at) },
{ label: '审批状态', value: selectedReceipt.value?.status === 'linked' ? '已关联' : '待关联' },
{ label: '是否已入账', value: '未入账' }
])
const operationLogs = computed(() => [
{
time: formatDateTime(selectedReceipt.value?.uploaded_at),
operator: fallback(selectedReceipt.value?.owner_name || selectedReceipt.value?.owner || '系统'),
label: '上传票据'
},
{
time: formatDateTime(selectedReceipt.value?.uploaded_at),
operator: '系统',
label: `OCR识别提取 ${editableOtherFields.value.length} 项要素`
},
{
time: formatDateTime(selectedReceipt.value?.linked_at || selectedReceipt.value?.uploaded_at),
operator: selectedReceipt.value?.status === 'linked' ? '系统' : '待处理',
label: selectedReceipt.value?.status === 'linked' ? `关联单据 ${selectedReceipt.value?.linked_claim_no || ''}` : '等待关联单据'
}
])
const archiveInfoItems = computed(() => [
{ label: '归档编号', value: archiveNo.value },
{ label: '归档目录', value: `${dateOnly(selectedReceipt.value?.uploaded_at)} / ${fallback(detailForm.scene_label)}` },
{ label: '保管期限', value: '10年' },
{ label: '关联附件数量', value: selectedReceipt.value?.status === 'linked' ? '1' : '0' },
{ label: '文件格式', value: fileFormat.value },
{ label: '文件大小', value: fallback(selectedReceipt.value?.file_size_label || selectedReceipt.value?.size_label, '待统计') }
])
const linkedClaimName = computed(() => (
selectedReceipt.value?.linked_claim_no
? `${fallback(detailForm.scene_label)}票据归集`
: '暂未关联报销单'
))
const archiveNo = computed(() => (
selectedReceipt.value?.id ? `DA-${String(selectedReceipt.value.id).slice(0, 8).toUpperCase()}` : '待生成'
))
const fileFormat = computed(() => {
const fileName = String(detailForm.file_name || selectedReceipt.value?.file_name || '').trim()
const suffix = fileName.includes('.') ? fileName.split('.').pop() : ''
return suffix ? suffix.toUpperCase() : fallback(selectedReceipt.value?.preview_kind, '待识别')
})
function adjustPreviewZoom(delta) {
previewZoom.value = Math.min(1.8, Math.max(0.6, Number((previewZoom.value + delta).toFixed(2))))
}
function resetPreviewView() {
previewZoom.value = 1
previewRotation.value = 0
}
function rotatePreview() {
previewRotation.value = (previewRotation.value + 90) % 360
}
return {
adjustPreviewZoom,
archiveInfoItems,
basicInfoItems,
linkedClaimItems,
ocrPreviewFields,
operationLogs,
previewPageLabel,
previewRotation,
previewTransform,
previewZoom,
receiptStatusItems,
resetPreviewView,
rotatePreview
}
}
function fallback(value, empty = '待补充') {
const text = String(value || '').trim()
return text || empty
}
function dateOnly(value) {
const text = String(value || '').trim()
return text ? text.slice(0, 10) : '待确认'
}

View File

@@ -503,6 +503,7 @@ export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
businessStage: 'reimbursement',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
@@ -515,6 +516,7 @@ export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
businessStage: 'reimbursement',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',

View File

@@ -3,6 +3,11 @@ import {
isRiskSummaryWithRisk,
normalizeRiskFlagTone
} from '../../utils/riskFlags.js'
import {
resolveRiskActionability,
resolveRiskDomain,
resolveRiskVisibilityScope
} from '../../utils/riskVisibility.js'
const DOCUMENT_TYPE_LABELS = {
flight_itinerary: '机票/航班行程单',
@@ -28,6 +33,121 @@ function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
function normalizeBusinessStage(value) {
const stage = normalizeText(value).toLowerCase()
if ([
'expense_application',
'application',
'apply',
'pre_apply',
'pre_application',
'budget_application'
].includes(stage)) {
return 'expense_application'
}
if ([
'reimbursement',
'expense_reimbursement',
'claim',
'expense_claim',
'expense_report'
].includes(stage)) {
return 'reimbursement'
}
return ''
}
function resolveFlagBusinessStage(flag, fallback = 'reimbursement') {
if (!flag || typeof flag !== 'object') {
return resolveRiskTextBusinessStage(flag, fallback)
}
const explicitStage = normalizeBusinessStage(
flag.businessStage
|| flag.business_stage
|| flag.controlStage
|| flag.control_stage
)
if (explicitStage) {
return explicitStage
}
const source = normalizeText(flag.source).toLowerCase()
const eventType = normalizeText(flag.event_type || flag.eventType).toLowerCase()
if (source === 'attachment_analysis' || /expense_claim|reimbursement|payment/.test(eventType)) {
return 'reimbursement'
}
if (/application/.test(source) || /expense_application/.test(eventType)) {
return 'expense_application'
}
return resolveRiskTextBusinessStage(cardLikeText(flag), fallback)
}
function resolveRiskTextBusinessStage(value, fallback = 'reimbursement') {
const text = normalizeText(value)
if (/报销|附件|单据|票据|发票|OCR|识别|付款|支付|酒店|住宿票|交通票/.test(text)) {
return 'reimbursement'
}
if (/申请|预算|额度|事前|预估|申请金额|申请事由/.test(text)) {
return 'expense_application'
}
return fallback
}
function cardLikeText(card = {}) {
return [
card.label,
card.title,
card.risk,
card.message,
card.summary,
card.suggestion,
card.description,
card.detail
].map((item) => normalizeText(item)).join(' ')
}
function resolveRequestBusinessStage(request = {}) {
const explicitStage = normalizeBusinessStage(
request?.businessStage
|| request?.business_stage
|| request?.controlStage
|| request?.control_stage
)
if (explicitStage) {
return explicitStage
}
const documentType = normalizeText(
request?.documentTypeCode
|| request?.document_type_code
|| request?.documentType
|| request?.document_type
).toLowerCase()
if (['application', 'expense_application'].includes(documentType)) {
return 'expense_application'
}
const claimNo = normalizeText(
request?.claimNo
|| request?.claim_no
|| request?.documentNo
|| request?.document_no
|| request?.id
).toUpperCase()
if (claimNo.startsWith('AP-') || claimNo.startsWith('APP-')) {
return 'expense_application'
}
const typeCode = normalizeText(request?.typeCode || request?.expense_type).toLowerCase()
if (typeCode === 'application' || typeCode.endsWith('_application')) {
return 'expense_application'
}
return 'reimbursement'
}
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (tone === 'pass') return 'pass'
@@ -37,6 +157,14 @@ function normalizeTone(value) {
return 'medium'
}
function resolveRiskLevelLabel(tone) {
const normalizedTone = normalizeTone(tone)
if (normalizedTone === 'high') return '高风险'
if (normalizedTone === 'medium') return '中风险'
if (normalizedTone === 'low') return '低风险'
return '风险提示'
}
export function normalizeRiskTone(value) {
return normalizeTone(value)
}
@@ -143,12 +271,34 @@ export function resolveRiskTags(card = {}) {
}
function withRiskTags(card) {
const businessStage = normalizeBusinessStage(
card.businessStage
|| card.business_stage
|| card.controlStage
|| card.control_stage
)
const riskDomain = resolveRiskDomain(card)
const actionability = resolveRiskActionability(card, { businessStage, riskDomain })
const visibilityScope = resolveRiskVisibilityScope(card, { businessStage, riskDomain, actionability })
return {
...card,
...(businessStage ? { businessStage } : {}),
riskDomain,
risk_domain: riskDomain,
actionability,
visibilityScope,
visibility_scope: visibilityScope,
tags: resolveRiskTags(card)
}
}
export function filterRiskCardsByBusinessStage(cards = [], businessStage = 'reimbursement') {
const targetStage = normalizeBusinessStage(businessStage) || 'reimbursement'
return (Array.isArray(cards) ? cards : []).filter(
(card) => resolveFlagBusinessStage(card, targetStage) === targetStage
)
}
function resolveDocumentTypeLabel(value) {
return DOCUMENT_TYPE_LABELS[normalizeText(value)] || DOCUMENT_TYPE_LABELS.other
}
@@ -286,21 +436,24 @@ function buildCardSuggestion(analysis, insight) {
)
}
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }) {
function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis, businessStage = 'reimbursement' }) {
const tone = normalizeTone(analysis?.severity)
const label = normalizeText(analysis?.label) || (tone === 'high' ? '高风险' : '风险')
const title = normalizeText(analysis?.headline) || normalizeText(analysis?.label) || normalizeText(item?.name) || '附件风险'
return withRiskTags({
id: `${normalizeText(item?.id) || `expense-${index}`}-${pointIndex}`,
businessStage: normalizeBusinessStage(businessStage) || 'reimbursement',
tone,
label,
title: `${index + 1} 条:${normalizeText(analysis?.headline) || normalizeText(item?.name) || '附件风险'}`,
label: resolveRiskLevelLabel(tone),
title: `${index + 1} 条:${title}`,
risk: normalizeText(point) || normalizeText(analysis?.summary) || '附件存在待核对风险。',
summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight),
itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel)
documentType: normalizeText(insight?.documentTypeLabel),
visibility_scope: 'submitter',
actionability: 'fixable_by_submitter'
})
}
@@ -334,7 +487,7 @@ function resolveLatestManualReturnFlag(flags) {
}, manualReturnFlags[0])
}
function buildManualReturnRiskCard(flag) {
function buildManualReturnRiskCard(flag, businessStage = 'reimbursement') {
if (!flag) {
return null
}
@@ -355,21 +508,27 @@ function buildManualReturnRiskCard(flag) {
return withRiskTags({
id: `manual-return-${returnCount || 'latest'}`,
businessStage: resolveFlagBusinessStage(flag, normalizeBusinessStage(businessStage) || 'reimbursement'),
tone: 'medium',
label: '退回原因',
title: returnCount ? `${returnCount} 次退回` : '审批退回',
risk,
summary: normalizeText(flag.reason),
ruleBasis: ruleBasis.length ? ruleBasis : ['审批人已退回该单据。'],
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。'
suggestion: '请按退回原因补充材料、修正明细或完善说明后重新提交。',
risk_domain: flag.risk_domain || flag.riskDomain || 'workflow',
visibility_scope: flag.visibility_scope || flag.visibilityScope || 'submitter',
actionability: flag.actionability || 'fixable_by_submitter'
})
}
export function buildAttachmentRiskCards({
expenseItems = [],
attachmentMetaByItemId = {},
claimRiskFlags = []
claimRiskFlags = [],
businessStage = 'reimbursement'
} = {}) {
const normalizedBusinessStage = normalizeBusinessStage(businessStage) || 'reimbursement'
const attachmentRiskItemIds = new Set()
const attachmentCards = expenseItems.flatMap((item, index) => {
if (!item?.invoiceId) {
@@ -393,17 +552,31 @@ export function buildAttachmentRiskCards({
: [analysis.summary || analysis.headline || analysis.label]
return points
.map((point, pointIndex) => buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analysis }))
.map((point, pointIndex) => buildRiskCardFromPoint({
item,
index,
point,
pointIndex,
insight,
analysis,
businessStage: normalizedBusinessStage
}))
.filter((card) => card.risk)
})
const normalizedClaimRiskFlags = Array.isArray(claimRiskFlags) ? claimRiskFlags : []
const latestManualReturnCard = buildManualReturnRiskCard(resolveLatestManualReturnFlag(normalizedClaimRiskFlags))
const latestManualReturnCard = buildManualReturnRiskCard(
resolveLatestManualReturnFlag(normalizedClaimRiskFlags),
normalizedBusinessStage
)
const claimCards = normalizedClaimRiskFlags
.flatMap((flag, index) => {
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'manual_return') {
return []
}
if (flag && typeof flag === 'object' && normalizeText(flag.source) === 'ai_pre_review') {
return []
}
if (!flag || typeof flag !== 'object') {
if (!isActionableRiskFlag(flag)) {
@@ -414,6 +587,7 @@ export function buildAttachmentRiskCards({
return risk
? [withRiskTags({
id: `claim-risk-${index}`,
businessStage: resolveRiskTextBusinessStage(risk, normalizedBusinessStage),
tone: 'medium',
label: '单据风险',
title: '单据风险提示',
@@ -457,13 +631,17 @@ export function buildAttachmentRiskCards({
return risks.map((risk, pointIndex) => withRiskTags({
id: `claim-risk-${index}-${pointIndex}`,
businessStage: resolveFlagBusinessStage(flag, normalizedBusinessStage),
tone,
label: normalizeText(flag.label) || (tone === 'high' ? '高风险' : '中风险'),
title: normalizeText(flag.label) || '单据风险提示',
label: resolveRiskLevelLabel(tone),
title: normalizeText(flag.title || flag.label || flag.name || flag.rule_name || flag.ruleCode || flag.rule_code) || '单据风险提示',
risk,
summary,
ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary })
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope,
actionability: flag.actionability
}))
})
.filter(Boolean)
@@ -504,11 +682,13 @@ export function buildClaimSummaryRiskCards(request = {}) {
if (!isRiskTone(tone)) {
return []
}
const businessStage = resolveRiskTextBusinessStage(summary, resolveRequestBusinessStage(request))
return [withRiskTags({
id: 'claim-risk-summary',
businessStage,
tone,
label: tone === 'high' ? '高风险' : '中风险',
label: resolveRiskLevelLabel(tone),
title: '单据风险提示',
risk: summary,
summary,
@@ -524,6 +704,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean)
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
const items = [
@@ -553,8 +734,9 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
if (normalizedRiskCards.length) {
sections.push({
kind: 'risk',
title: '已知存在风险',
items: normalizedRiskCards
title: `已知存在风险${normalizedRiskCards.length}项)`,
items: sortedRiskCards,
totalCount: normalizedRiskCards.length
})
}
@@ -562,10 +744,25 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对',
summary: normalizedRiskCards.length
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议`
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示`
: '建议先补齐必填信息,完成后即可提交审批。',
items: normalizedCompletionItems,
riskCards: normalizedRiskCards,
sections
}
}
function sortRiskCardsByTone(cards) {
const toneWeight = {
high: 0,
medium: 1,
low: 2,
normal: 3,
pass: 4
}
return [...cards].sort((left, right) => {
const leftWeight = toneWeight[normalizeText(left?.tone).toLowerCase()] ?? 9
const rightWeight = toneWeight[normalizeText(right?.tone).toLowerCase()] ?? 9
return leftWeight - rightWeight
})
}

View File

@@ -0,0 +1,74 @@
export function isAiPreReviewFlag(flag) {
if (!flag || typeof flag !== 'object') {
return false
}
const source = String(flag.source || '').trim()
const eventType = String(flag.event_type || flag.eventType || '').trim()
return source === 'ai_pre_review' || eventType === 'expense_claim_ai_pre_review'
}
export function findLatestAiPreReviewEvent(flags = []) {
return flags
.filter(isAiPreReviewFlag)
.map((flag) => ({
...flag,
eventTime: new Date(flag.created_at || flag.createdAt || 0).getTime()
}))
.sort((left, right) => (left.eventTime || 0) - (right.eventTime || 0))
.pop() || null
}
export function buildAiPreReviewSnapshot(payload, fallbackClaimId = '') {
return {
claimId: String(payload?.id || fallbackClaimId || '').trim(),
riskFlags: Array.isArray(payload?.risk_flags_json) ? payload.risk_flags_json : []
}
}
export function isAiPreReviewPassed(event, requiresAiPreReview) {
if (!requiresAiPreReview) {
return true
}
return Boolean(event?.passed) || String(event?.status || '').trim() === 'passed'
}
export function resolveSubmitActionLabel({
isApplicationDocument,
hasAiPreReviewResult,
submitBusy
}) {
if (isApplicationDocument) {
return submitBusy ? '提交中' : '提交审批'
}
if (!hasAiPreReviewResult) {
return submitBusy ? '审核中' : 'AI审核'
}
return submitBusy ? '提交中' : '下一步'
}
export function resolveSubmitActionIcon({ isApplicationDocument, hasAiPreReviewResult }) {
if (isApplicationDocument) {
return 'mdi mdi-send-circle-outline'
}
return hasAiPreReviewResult ? 'mdi mdi-arrow-right-circle-outline' : 'mdi mdi-shield-check-outline'
}
export function resolveSubmitConfirmDescription({ isApplicationDocument, aiPreReviewPassed }) {
if (isApplicationDocument) {
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (!aiPreReviewPassed) {
return 'AI预审存在重大风险请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
}
return 'AI预审已完成请确认费用明细、附件材料和风险说明均已核对无误。确认后将进入审批流程。'
}
export function resolveSubmitConfirmText(isApplicationDocument) {
return isApplicationDocument ? '确认提交' : '确认下一步'
}
export function resolveAiPreReviewToast(event) {
return event && (event.passed || event.status === 'passed')
? 'AI预审通过请点击下一步提交审批。'
: 'AI预审发现重大风险请核对 AI建议 后再点击下一步。'
}

View File

@@ -4,8 +4,10 @@ import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
buildApplicationPreviewRows,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
normalizeApplicationPreview,
refreshApplicationPreviewTransportEstimate
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import {
buildWorkbenchDateLabel,
canApplyWorkbenchDateSelection,
@@ -33,10 +35,28 @@ function buildEmptyEditor() {
dateMode: 'single',
singleDate: getTodayDateValue(),
rangeStartDate: getTodayDateValue(),
rangeEndDate: getTodayDateValue()
rangeEndDate: getTodayDateValue(),
committing: false
}
}
function shouldRefreshTransportEstimate(fieldKey) {
return ['transportMode', 'time', 'location', 'days'].includes(fieldKey)
}
function buildTransportEstimatePendingPreview(preview = {}) {
const fields = preview?.fields || {}
return normalizeApplicationPreview({
...preview,
fields: {
...fields,
transportPolicy: '正在查询交通参考票价...',
policyEstimate: '正在同步费用测算...',
transportEstimatedAmount: '查询中'
}
})
}
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
const applicationPreviewEditor = ref(buildEmptyEditor())
@@ -74,6 +94,7 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
draftValue: fieldKey === 'transportMode' && !APPLICATION_TRANSPORT_MODE_OPTIONS.includes(normalizedValue)
? ''
: normalizedValue,
committing: false,
...dateState
}
}
@@ -110,18 +131,29 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
})
}
function commitApplicationPreviewEditor(message) {
async function commitApplicationPreviewEditor(message) {
const editor = applicationPreviewEditor.value
if (editor.committing) {
return false
}
if (!message?.applicationPreview || String(editor.messageId || '') !== String(message.id || '') || !editor.fieldKey) {
cancelApplicationPreviewEditor()
return false
}
applicationPreviewEditor.value = {
...editor,
committing: true
}
const nextValue = editor.fieldKey === 'time'
? buildApplicationPreviewDateDraftValue()
: String(editor.draftValue || '').trim()
if (editor.fieldKey === 'time' && !nextValue) {
toast?.('请先选择有效日期。')
applicationPreviewEditor.value = {
...applicationPreviewEditor.value,
committing: false
}
return false
}
const nextPreview = normalizeApplicationPreview({
@@ -131,15 +163,31 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
[editor.fieldKey]: nextValue
}
})
message.applicationPreview = nextPreview
message.text = buildLocalApplicationPreviewMessage(nextPreview)
const needRefreshTransport = shouldRefreshTransportEstimate(editor.fieldKey) && String(nextPreview.fields?.transportMode || '').trim()
message.applicationPreview = needRefreshTransport
? buildTransportEstimatePendingPreview(nextPreview)
: nextPreview
message.text = buildLocalApplicationPreviewMessage(message.applicationPreview)
cancelApplicationPreviewEditor()
persistSessionState?.()
if (needRefreshTransport) {
await waitForMockApplicationTransportQuote({
transportMode: nextPreview.fields.transportMode,
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
time: nextPreview.fields.time
})
const refreshedPreview = refreshApplicationPreviewTransportEstimate(nextPreview)
message.applicationPreview = refreshedPreview
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
persistSessionState?.()
toast?.('已更新出行方式和费用测算。')
return true
}
toast?.('已更新核对表内容。')
return true
}
function commitApplicationPreviewDateEditor(message) {
async function commitApplicationPreviewDateEditor(message) {
if (!canApplyApplicationPreviewDateSelection()) {
toast?.('请确认结束日期不早于开始日期。')
return false

View File

@@ -112,9 +112,12 @@ export function useAuditAssetData({
await loadAssets({ force: true, silent: true, background: true })
}
async function loadSelectedAssetDetail(assetId) {
detailLoading.value = true
detailError.value = ''
async function loadSelectedAssetDetail(assetId, options = {}) {
const silent = Boolean(options.silent)
if (!silent) {
detailLoading.value = true
detailError.value = ''
}
try {
if (!runs.value.length) {
@@ -155,10 +158,17 @@ export function useAuditAssetData({
}
}
} catch (error) {
detailError.value = error?.message || '资产详情加载失败,请稍后重试。'
toast(detailError.value)
const message = error?.message || '资产详情加载失败,请稍后重试。'
if (silent) {
console.warn('Silent asset detail refresh failed:', error)
} else {
detailError.value = message
toast(message)
}
} finally {
detailLoading.value = false
if (!silent) {
detailLoading.value = false
}
}
}

View File

@@ -5,13 +5,14 @@ import {
deleteAgentAsset,
fetchAgentAssetDetail,
publishRiskRuleAsset,
regenerateRiskRuleAsset,
returnRiskRuleAsset,
setRiskRuleAssetEnabled,
updateRiskRuleDraft
} from '../../services/agentAssets.js'
import { normalizeText } from './auditViewModel.js'
const DEFAULT_EXPENSE_CATEGORY = 'travel'
const DEFAULT_EXPENSE_CATEGORY = 'all'
export function useAuditRiskRuleActions({
selectedSkill,
@@ -127,16 +128,22 @@ export function useAuditRiskRuleActions({
}
actionState.value = 'save-risk-rule-edit'
const assetId = selectedSkill.value.id
try {
const detail = isRevision
? await createRiskRuleRevision(selectedSkill.value.id, payload, { actor: resolveActor() })
: await updateRiskRuleDraft(selectedSkill.value.id, payload, { actor: resolveActor() })
const actor = resolveActor()
if (isRevision) {
await createRiskRuleRevision(assetId, payload, { actor })
} else {
await updateRiskRuleDraft(assetId, payload, { actor })
}
const regenerated = await regenerateRiskRuleAsset(assetId, buildRegeneratePayload(payload), { actor })
riskRuleEditOpen.value = false
mergeSelectedRuleLifecycle(detail)
await refreshCurrentAssets()
toast(isRevision ? '已创建风险规则修订草稿。' : '风险规则草稿已更新。')
mergeSelectedRuleLifecycle(regenerated)
await loadSelectedAssetDetail(assetId, { silent: true })
toast(isRevision ? '已创建修订草稿并重新生成规则。' : '风险规则草稿已保存并重新生成。')
} catch (error) {
toast(error?.message || (isRevision ? '创建修订版本失败,请稍后重试。' : '编辑规则草稿失败,请稍后重试。'))
toast(error?.message || (isRevision ? '创建并生成修订版本失败,请稍后重试。' : '保存并生成规则草稿失败,请稍后重试。'))
} finally {
actionState.value = ''
}
@@ -207,7 +214,7 @@ export function useAuditRiskRuleActions({
await returnRiskRuleAsset(selectedSkill.value.id, { note }, { actor: resolveActor() })
riskRuleReturnOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast('风险规则已回退到草稿。')
} catch (error) {
toast(error?.message || '风险规则回退失败,请稍后重试。')
@@ -243,7 +250,7 @@ export function useAuditRiskRuleActions({
await publishRiskRuleAsset(selectedSkill.value.id, { actor: resolveActor() })
riskRulePublishOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast('风险规则已发布上线。')
} catch (error) {
toast(error?.message || '风险规则发布失败,请稍后重试。')
@@ -328,3 +335,12 @@ function normalizeRiskRuleEditPayload(form, includeReason) {
}
return payload
}
function buildRegeneratePayload(payload) {
return {
rule_title: payload.rule_title,
expense_category: payload.expense_category,
requires_attachment: payload.requires_attachment,
natural_language: payload.natural_language
}
}

View File

@@ -60,7 +60,7 @@ export function useAuditRiskRuleCreateFlow({
try {
const detail = await generateRiskRuleAsset(
{
business_domain: 'expense',
business_domain: riskRuleCreateForm.value.business_domain || 'expense',
business_stage: riskRuleCreateForm.value.business_stage,
expense_category: riskRuleCreateForm.value.expense_category,
rule_title: ruleTitle,

View File

@@ -68,7 +68,7 @@ export function useAuditRuleReviewFlow({
{ actor: resolveActor() }
)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast(`当前规则版本已标记为${resolveReviewMeta(reviewStatus).label}`)
} catch (error) {
toast(error?.message || '规则审核提交失败,请稍后重试。')
@@ -161,7 +161,7 @@ export function useAuditRuleReviewFlow({
)
reviewSubmitOpen.value = false
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast(`规则版本 ${version} 已提交给 ${reviewer} 审核。`)
} catch (error) {
toast(error?.message || '规则审核提交失败,请稍后重试。')

View File

@@ -75,7 +75,7 @@ export function useAuditRuleVersionActions({
)
await persistRuleRuntimeConfig(selectedSkill.value, runtimeRule)
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast(`${successLabel} ${nextVersion}`)
} catch (error) {
toast(error?.message || `${successLabel}失败,请稍后重试。`)
@@ -109,7 +109,7 @@ export function useAuditRuleVersionActions({
try {
await activateAgentAsset(selectedSkill.value.id, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast('规则已正式上线。')
} catch (error) {
toast(error?.message || '规则上线失败,请稍后重试。')
@@ -133,7 +133,7 @@ export function useAuditRuleVersionActions({
try {
await restoreAgentAssetVersion(selectedSkill.value.id, version, { actor: resolveActor() })
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSelectedAssetDetail(selectedSkill.value.id, { silent: true })
toast(`已基于 ${version} 生成新的工作版本。`)
} catch (error) {
toast(error?.message || '历史版本恢复失败,请稍后重试。')

View File

@@ -101,6 +101,7 @@ export function useTravelReimbursementGuidedFlow({
lockSuggestedActionMessage,
submitExistingComposer,
currentUser,
refreshCurrentUserFromBackend,
toast
}) {
const guidedPendingFiles = ref([])
@@ -151,9 +152,19 @@ export function useTravelReimbursementGuidedFlow({
persistAndScroll()
}
function startGuidedApplicationTemplate() {
async function resolveApplicationPreviewUser() {
const user = currentUser?.value || {}
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
return user
}
await refreshCurrentUserFromBackend({ silent: true })
return currentUser?.value || user
}
async function startGuidedApplicationTemplate() {
resetGuidedFlowState()
const applicationPreview = buildApplicationTemplatePreview(currentUser?.value || {})
const applicationPreview = buildApplicationTemplatePreview(await resolveApplicationPreviewUser())
pushAssistant(buildLocalApplicationPreviewMessage(applicationPreview), {
meta: ['申请模板'],
applicationPreview
@@ -171,10 +182,10 @@ export function useTravelReimbursementGuidedFlow({
persistAndScroll()
}
function handleGuidedShortcut(shortcut) {
async function handleGuidedShortcut(shortcut) {
const actionType = normalizeText(shortcut?.action)
if (actionType === GUIDED_ACTION_START_APPLICATION) {
startGuidedApplicationTemplate()
await startGuidedApplicationTemplate()
return true
}
if (actionType === GUIDED_ACTION_START_REIMBURSEMENT) {
@@ -245,6 +256,7 @@ export function useTravelReimbursementGuidedFlow({
claimsPayload = await fetchExpenseClaims()
} catch (error) {
console.warn('Fetch reimbursement applications failed:', error)
guidedFlowState.value = createEmptyGuidedFlowState()
pushAssistant('查询可关联申请单时出现异常,请稍后再试。为避免直接报销,我先暂停当前流程。', {
meta: ['申请单查询失败']
})
@@ -254,10 +266,9 @@ export function useTravelReimbursementGuidedFlow({
const applications = filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser?.value || {})
if (!applications.length) {
guidedFlowState.value = createGuidedReimbursementState()
guidedFlowState.value = createEmptyGuidedFlowState()
pushAssistant(buildRequiredApplicationMissingText(expenseType), {
meta: ['缺少可关联申请单'],
suggestedActions: buildGuidedExpenseTypeActions()
meta: ['缺少可关联申请单']
})
return
}

View File

@@ -13,6 +13,7 @@ import {
buildModelRefinedApplicationPreview,
shouldUseLocalApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
import { fetchOntologyParse } from '../../services/ontology.js'
import { buildExpenseApplicationOntologyContext } from '../../utils/expenseApplicationOntology.js'
import { calculateTravelReimbursement } from '../../services/reimbursements.js'
@@ -79,6 +80,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
persistSessionState,
props,
recognizeOcrFiles,
refreshCurrentUserFromBackend,
refreshFlowRunDetail,
rememberFilePreviews,
replaceMessage,
@@ -339,8 +341,18 @@ export function useTravelReimbursementSubmitComposer(ctx) {
]
}
async function buildApplicationPreviewWithModelReview(rawText) {
async function resolveApplicationPreviewUser() {
const user = currentUser.value || {}
if (String(user.position || '').trim() || typeof refreshCurrentUserFromBackend !== 'function') {
return user
}
await refreshCurrentUserFromBackend({ silent: true })
return currentUser.value || user
}
async function buildApplicationPreviewWithModelReview(rawText) {
const user = await resolveApplicationPreviewUser()
const localPreview = buildLocalApplicationPreview(rawText, user)
const enrichWithPolicyEstimate = async (preview) => {
@@ -349,6 +361,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return preview
}
try {
const fields = preview?.fields || {}
await waitForMockApplicationTransportQuote({
transportMode: fields.transportMode,
location: fields.location,
time: fields.time
})
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
@@ -548,14 +566,14 @@ export function useTravelReimbursementSubmitComposer(ctx) {
startFlowStep('application-review-preview', {
title: '申请信息核对',
tool: 'ontology.application_review',
detail: '正在进行申请信息模型复核...'
detail: '正在复核申请信息,并查询交通票价...'
})
if (!options.skipUserMessage) {
messages.value.push(createMessage('user', userText, fileNames))
}
const pendingMessage = createMessage(
'assistant',
'正在进行申请信息模型复核。本步骤只识别意图和抽取字段,不会创建、更新或保存草稿。',
'正在复核申请信息,并查询交通票价,请稍候。',
[],
{
meta: ['模型复核中']
@@ -770,7 +788,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
isKnowledgeSession.value
? '正在整理财务知识答案...'
: activeSessionType.value === 'application'
? '正在识别并整理申请核对信息...'
? '正在识别申请信息并查询交通票价...'
: activeSessionType.value === 'approval'
? '正在查询审核上下文并整理风险提示...'
: '正在识别并整理右侧核对信息...'
@@ -1037,20 +1055,29 @@ export function useTravelReimbursementSubmitComposer(ctx) {
nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
if (!isKnowledgeSession.value && resolvedDraftClaimId && files.length) {
void syncComposerFilesToDraft(resolvedDraftClaimId, files)
.then((syncResult) => {
const persistComposerFilesToDraft = async () => {
try {
const syncResult = await syncComposerFilesToDraft(resolvedDraftClaimId, files)
persistSessionState()
if (detailScopedUpload && Number(syncResult?.uploadedCount || 0) > 0) {
if (detailScopedUpload) {
emitRequestUpdated?.({
claimId: resolvedDraftClaimId,
source: 'detail-smart-entry-attachment-sync'
source: 'detail-smart-entry-attachment-sync',
uploadedCount: Number(syncResult?.uploadedCount || 0),
skippedCount: Number(syncResult?.skippedCount || 0)
})
}
})
.catch((error) => {
} catch (error) {
console.warn('Failed to persist composer attachments to draft claim:', error)
toast(error?.message || '票据已归集到草稿,但附件原件保存失败,请在单据详情中重新上传。')
})
}
}
const persistTask = persistComposerFilesToDraft()
if (detailScopedUpload) {
await persistTask
} else {
void persistTask
}
}
} catch (error) {
clearFlowSimulationTimers()