feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -4,6 +4,7 @@ import { ElButton } from 'element-plus/es/components/button/index.mjs'
import BudgetTrendChart from '../../components/charts/BudgetTrendChart.vue'
import EnterpriseDetailCard from '../../components/shared/EnterpriseDetailCard.vue'
import EnterpriseDetailPage from '../../components/shared/EnterpriseDetailPage.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
@@ -98,6 +99,7 @@ export default {
emits: ['openAssistant', 'detail-open-change', 'detail-topbar-change'],
components: {
BudgetTrendChart,
EnterprisePagination,
EnterpriseSelect,
EnterpriseDetailCard,
EnterpriseDetailPage,
@@ -169,9 +171,6 @@ export default {
const currentBudgetPage = computed(() =>
Math.min(Math.max(1, budgetPage.value), totalBudgetPages.value)
)
const budgetPageNumbers = computed(() =>
Array.from({ length: totalBudgetPages.value }, (_, index) => index + 1)
)
const visibleBudgetRows = computed(() => {
const pageSize = Number(budgetPageSize.value || 8)
const start = (currentBudgetPage.value - 1) * pageSize
@@ -227,7 +226,7 @@ export default {
artLabel: '预算列表为空',
tips: ['可以调整年度、季度、状态或关键词后重试。']
}))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`)
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value} / ${totalBudgetPages.value}`)
function buildBudgetAssistantContext(row, mode = 'edit') {
if (!row) return null
@@ -425,7 +424,6 @@ export default {
budgetKeyword,
budgetLoading,
budgetPage: currentBudgetPage,
budgetPageNumbers,
budgetPageSize,
budgetPageSizeOptions,
budgetScopeTabs,

View File

@@ -1,6 +1,7 @@
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import TableEmptyState from '../../components/shared/TableEmptyState.vue'
@@ -452,6 +453,7 @@ export default {
name: 'EmployeeManagementView',
components: {
ConfirmDialog,
EnterprisePagination,
EnterpriseSelect,
TableLoadingState,
TableEmptyState
@@ -672,6 +674,7 @@ export default {
const totalCount = computed(() => filteredEmployees.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const pageSummary = computed(() => `${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleEmployees = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
@@ -1469,6 +1472,7 @@ export default {
hasEmployeeFilters,
totalCount,
totalPages,
pageSummary,
resetFilters,
handleEmployeeEmptyAction,
openEmployeeDetail,

View File

@@ -152,12 +152,6 @@ export default {
filteredSystemLogEntries.value.filter((entry) => entry.level === 'INFO').length
)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visiblePageItems = computed(() => {
if (totalPages.value <= 6) {
return Array.from({ length: totalPages.value }, (_, index) => index + 1)
}
return [1, 2, 3, 4, 5, 'ellipsis', totalPages.value]
})
const visibleSystemLogEntries = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSystemLogEntries.value.slice(start, start + pageSize.value)
@@ -300,7 +294,6 @@ export default {
systemSearchKeyword,
totalCount,
totalPages,
visiblePageItems,
visibleSystemLogEntries
}
}

View File

@@ -1,7 +1,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import EnterpriseSelect from '../../components/shared/EnterpriseSelect.vue'
import EnterprisePagination from '../../components/shared/EnterprisePagination.vue'
import TableLoadingState from '../../components/shared/TableLoadingState.vue'
import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
@@ -87,7 +87,7 @@ export default {
name: 'PoliciesView',
components: {
ConfirmDialog,
EnterpriseSelect,
EnterprisePagination,
TableLoadingState
},
emits: ['summary-change'],
@@ -182,9 +182,10 @@ export default {
&& activeFolderIngestStats.value.syncing === 0
)
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const visibleDocuments = computed(() => {
const totalCount = computed(() => filteredDocuments.value.length)
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)))
const pageSummary = computed(() => `${totalCount.value} 条,目前第 ${currentPage.value} / ${totalPages.value}`)
const visibleDocuments = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredDocuments.value.slice(start, start + pageSize.value)
})
@@ -636,6 +637,7 @@ export default {
loading,
pageSize,
pageSizeOptions,
pageSummary,
pageSizes,
onlyOfficeError,
onlyOfficeHostId,

View File

@@ -49,6 +49,13 @@ import {
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
TRAVEL_PLANNING_ACTION_GENERATE,
TRAVEL_PLANNING_ACTION_SKIP,
buildTravelPlanningNudgeMessage,
buildTravelPlanningRecommendation,
buildTravelPlanningSuggestedActions
} from '../../utils/travelApplicationPlanning.js'
import {
calculateTravelReimbursement,
createExpenseClaimItem,
@@ -524,6 +531,14 @@ export default {
type: String,
default: ''
},
initialPromptAutoSubmit: {
type: Boolean,
default: true
},
initialApplicationPreview: {
type: Object,
default: null
},
initialFiles: {
type: Array,
default: () => []
@@ -629,7 +644,9 @@ export default {
handleApplicationPreviewEditorKeydown
} = useApplicationPreviewEditor({
persistSessionState,
toast
toast,
calculateTravelReimbursement,
currentUser
})
function applyLinkedApplicationPreviewDateSelection(selection) {
@@ -1372,6 +1389,14 @@ export default {
currentInsight.value =
currentInsight.value
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
if (props.initialApplicationPreview && typeof props.initialApplicationPreview === 'object') {
const applicationPreview = normalizeApplicationPreview(props.initialApplicationPreview)
messages.value.push(createMessage('assistant', buildLocalApplicationPreviewMessage(applicationPreview), [], {
meta: ['修改申请'],
applicationPreview
}))
persistSessionState()
}
if (props.initialPrompt?.trim() || props.initialFiles.length) {
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
composerDraft.value = props.initialPrompt.trim()
@@ -1380,7 +1405,12 @@ export default {
if (initialMerge.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
submitComposer()
nextTick(() => {
adjustComposerTextareaHeight()
})
if (props.initialPromptAutoSubmit !== false) {
submitComposer()
}
}
})
@@ -1576,6 +1606,32 @@ export default {
if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === TRAVEL_PLANNING_ACTION_GENERATE) {
if (!lockSuggestedActionMessage(message, action)) return
const sourcePreview = action?.payload?.applicationPreview || action?.payload?.preview || null
const sourceDraftPayload = action?.payload?.draftPayload || action?.payload?.draft_payload || null
const recommendation = buildTravelPlanningRecommendation(sourcePreview, sourceDraftPayload)
if (recommendation) {
messages.value.push(createMessage('user', '生成行程规划'))
messages.value.push(createMessage('assistant', recommendation, [], {
meta: ['行程规划建议']
}))
nextTick(scrollToBottom)
persistSessionState()
}
return
}
if (actionType === TRAVEL_PLANNING_ACTION_SKIP) {
if (!lockSuggestedActionMessage(message, action)) return
messages.value.push(createMessage('assistant', '好的,本次先保留申请结果。后续需要规划交通或酒店时,可以继续在这里告诉我。', [], {
meta: ['暂不规划']
}))
nextTick(scrollToBottom)
persistSessionState()
return
}
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const targetSessionType = String(actionPayload.session_type || '').trim()
@@ -2033,6 +2089,17 @@ export default {
}
}
function resolveApplicationEditClaimId() {
if (activeSessionType.value !== SESSION_TYPE_APPLICATION) {
return ''
}
const request = linkedRequest.value || {}
if (!request.applicationEditMode) {
return ''
}
return String(request.claimId || request.claim_id || '').trim()
}
async function confirmApplicationSubmit() {
const message = applicationSubmitConfirmDialog.value.message
if (!message || submitting.value || reviewActionBusy.value) {
@@ -2044,6 +2111,7 @@ export default {
const applicationSubmitText = applicationPreview
? buildApplicationPreviewSubmitText(applicationPreview)
: '确认提交'
const applicationEditClaimId = resolveApplicationEditClaimId()
applicationSubmitConfirmDialog.value = {
open: false,
message: null
@@ -2059,7 +2127,16 @@ export default {
feedbackOperationType: 'submit_application',
extraContext: {
application_preview: applicationPreview,
user_input_text: applicationSubmitText
user_input_text: applicationSubmitText,
...(applicationEditClaimId
? {
application_edit_claim_id: applicationEditClaimId,
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
application_edit_mode: true,
draft_claim_id: applicationEditClaimId,
selected_claim_id: applicationEditClaimId
}
: {})
}
})
const draftPayload = payload?.result?.draft_payload || {}
@@ -2074,6 +2151,23 @@ export default {
documentType: 'application'
})
}
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
...action,
payload: {
...(action.payload || {}),
applicationPreview,
draftPayload
}
}))
if (planningText && planningActions.length) {
messages.value.push(createMessage('assistant', planningText, [], {
meta: ['行程规划推荐'],
suggestedActions: planningActions
}))
persistSessionState()
nextTick(scrollToBottom)
}
} finally {
reviewActionBusy.value = false
}

View File

@@ -460,11 +460,17 @@ export default {
const isDraftRequest = computed(() => request.value.approvalKey === 'draft')
const isEditableRequest = computed(() => ['draft', 'supplement'].includes(request.value.approvalKey))
const canOpenAiEntry = computed(() => isEditableRequest.value)
const canModifyReturnedApplication = computed(() => (
isApplicationDocument.value
&& isEditableRequest.value
&& isCurrentApplicant.value
&& String(request.value.status || '').trim().toLowerCase() === 'returned'
))
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => {
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value)
return isPlatformAdminUser(currentUser.value) || (isEditableRequest.value && isCurrentApplicant.value)
}
if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value)
@@ -1007,7 +1013,7 @@ export default {
if (analysis) {
return {
label: analysis.label || '已上传',
tone: analysis.severity === 'pass' ? 'pass' : analysis.severity || 'low',
tone: normalizeRiskTone(analysis.severity || 'low'),
headline: analysis.headline || 'AI提示',
summary: analysis.summary || '',
points: Array.isArray(analysis.points) ? analysis.points : [],
@@ -1858,7 +1864,9 @@ export default {
toast(
isArchivedRequest.value
? '已归档单据不能删除,只有高级管理员可以执行删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。'
: isApplicationDocument.value
? '当前申请单已进入审批流程,只有退回后申请人本人或系统管理员可以删除。'
: '当前单据已进入流程,只有高级财务人员可以删除。'
)
return
}
@@ -2019,6 +2027,76 @@ export default {
})
}
function buildApplicationEditPreview() {
const factEntries = applicationDetailFactItems.value
.map((item) => [String(item?.label || '').trim(), String(item?.value || '').trim()])
.filter(([label, value]) => label && value)
const facts = new Map(factEntries)
const pickFact = (...labels) => {
for (const label of labels) {
const value = facts.get(label)
if (value) {
return value
}
}
return ''
}
const tripStart = pickFact('出发时间')
const tripReturn = pickFact('返回时间')
const time = tripStart && tripReturn && tripStart !== tripReturn
? `${tripStart}${tripReturn}`
: pickFact('行程时间', '申请时间', '招待时间', '发生时间') || tripStart
return {
sourceText: '修改申请',
modelReviewStatus: 'template',
fields: {
applicationType: pickFact('申请类型') || request.value.typeLabel || '费用申请',
applicant: request.value.profileName || request.value.person || request.value.applicant || '',
grade: pickFact('职级') || request.value.profileGrade || '',
department: request.value.profileDepartment || request.value.departmentName || request.value.department || '',
position: request.value.profilePosition || request.value.employeePosition || request.value.position || '',
managerName: request.value.profileManager || request.value.managerName || request.value.manager || '',
time,
location: pickFact('地点') || request.value.location || request.value.city || '',
reason: pickFact('事由') || request.value.reason || '',
days: pickFact('天数'),
transportMode: pickFact('出行方式'),
lodgingDailyCap: pickFact('住宿上限/天'),
subsidyDailyCap: pickFact('补贴标准/天'),
transportPolicy: pickFact('交通费用口径'),
policyEstimate: pickFact('规则测算参考'),
amount: pickFact('系统预估费用', '用户预估费用', '预计金额') || request.value.amountDisplay || request.value.amount || ''
}
}
}
function handleModifyApplication() {
if (!canModifyReturnedApplication.value) {
return
}
const claimId = String(request.value?.claimId || '').trim()
emit('openAssistant', {
source: 'application',
sessionType: 'application',
prompt: '',
applicationPreview: buildApplicationEditPreview(),
request: {
...request.value,
applicationEditMode: true
},
restoreLatestConversation: false,
initialPromptAutoSubmit: false,
scope: claimId
? {
type: 'claim',
claimId
}
: null
})
}
onBeforeUnmount(() => {
closeAttachmentPreview()
})
@@ -2032,7 +2110,7 @@ export default {
applicationDetailFactItems, relatedApplicationFactItems,
approveBusyText, approveConfirmText, approveConfirmTitle, canDeleteRequest, canManageCurrentClaim,
canNavigateAttachmentPreview,
canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
canModifyReturnedApplication, canOpenAiEntry, canApproveRequest, canPayRequest, canReturnRequest, canSubmit, canPreviewAttachment,
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
@@ -2046,6 +2124,7 @@ export default {
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange,
handleModifyApplication,
handlePayRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
isMajorExpenseRisk,

View File

@@ -1,10 +1,28 @@
const DIGITAL_EMPLOYEE_AGENT = 'hermes'
export const DIGITAL_EMPLOYEE_SKILL_CATEGORY_OPTIONS = ['积累', '升级', '整理', '评估']
export const DIGITAL_EMPLOYEE_VISIBLE_TASK_TYPES = new Set([
'finance_dashboard_snapshot',
'digital_employee_reminder_scan',
'employee_behavior_profile_scan',
'department_expense_baseline_accumulate',
'budget_overrun_precontrol_evaluate',
'multi_evidence_consistency_evaluate',
'travel_spatiotemporal_consistency_evaluate',
'global_risk_scan',
'finance_policy_knowledge_organize'
])
const TASK_TYPE_LABELS = {
finance_dashboard_snapshot: '财务经营快照沉淀',
digital_employee_reminder_scan: '定时提醒与待办扫描',
daily_risk_scan: '每日风险巡检',
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
department_expense_baseline_accumulate: '部门费用基线沉淀',
budget_overrun_precontrol_evaluate: '预算占用与超标预警',
multi_evidence_consistency_evaluate: '单据多凭证一致性评估',
travel_spatiotemporal_consistency_evaluate: '差旅时空一致性评估',
weekly_ar_summary: '周度应收账龄汇总',
weekly_expense_report: '周度费用洞察',
rule_review_digest: '规则待审摘要',
@@ -15,9 +33,15 @@ const TASK_TYPE_LABELS = {
}
const TASK_TYPE_SKILL_CATEGORIES = {
finance_dashboard_snapshot: '整理',
digital_employee_reminder_scan: '升级',
daily_risk_scan: '评估',
global_risk_scan: '评估',
employee_behavior_profile_scan: '评估',
employee_behavior_profile_scan: '积累',
department_expense_baseline_accumulate: '积累',
budget_overrun_precontrol_evaluate: '评估',
multi_evidence_consistency_evaluate: '评估',
travel_spatiotemporal_consistency_evaluate: '评估',
weekly_ar_summary: '整理',
weekly_expense_report: '整理',
rule_review_digest: '升级',
@@ -145,6 +169,12 @@ export function isDigitalEmployeeAsset(source = {}) {
)
}
export function shouldDisplayDigitalEmployeeAsset(source = {}) {
const content = parseDigitalEmployeeContent(source.current_version_content)
const taskType = resolveDigitalEmployeeTaskType(source, content)
return DIGITAL_EMPLOYEE_VISIBLE_TASK_TYPES.has(taskType)
}
export function formatDigitalEmployeeCron(value) {
const raw = normalizeDigitalEmployeeText(value)
if (!raw) {

View File

@@ -18,9 +18,31 @@ const KNOWLEDGE_JOB_TYPES = new Set([
'finance_policy_knowledge_organize'
])
export const VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES = new Set([
'finance_dashboard_snapshot',
'digital_employee_reminder_scan',
'employee_behavior_profile_scan',
'department_expense_baseline_accumulate',
'budget_overrun_precontrol_evaluate',
'multi_evidence_consistency_evaluate',
'travel_spatiotemporal_consistency_evaluate',
'global_risk_scan',
'finance_policy_knowledge_organize'
])
const DAILY_COMPACT_TASK_TYPES = new Set([
'finance_dashboard_snapshot'
])
const TASK_TYPE_LABELS = {
finance_dashboard_snapshot: '财务经营快照沉淀',
digital_employee_reminder_scan: '定时提醒与待办扫描',
global_risk_scan: '财务风险图谱巡检',
employee_behavior_profile_scan: '员工行为画像巡检',
department_expense_baseline_accumulate: '部门费用基线沉淀',
budget_overrun_precontrol_evaluate: '预算占用与超标预警',
multi_evidence_consistency_evaluate: '单据多凭证一致性评估',
travel_spatiotemporal_consistency_evaluate: '差旅时空一致性评估',
risk_clue_collect: '风险线索归集',
finance_policy_knowledge_organize: '知识制度整理',
knowledge_index_sync: '知识制度整理',
@@ -29,10 +51,16 @@ const TASK_TYPE_LABELS = {
}
const TASK_CODE_TO_TYPE = {
'task.hermes.finance_dashboard_snapshot': 'finance_dashboard_snapshot',
'task.hermes.digital_employee_reminder_scan': 'digital_employee_reminder_scan',
'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'
'task.hermes.department_expense_baseline_accumulate': 'department_expense_baseline_accumulate',
'task.hermes.budget_overrun_precontrol_evaluate': 'budget_overrun_precontrol_evaluate',
'task.hermes.multi_evidence_consistency_evaluate': 'multi_evidence_consistency_evaluate',
'task.hermes.travel_spatiotemporal_consistency_evaluate': 'travel_spatiotemporal_consistency_evaluate',
'task.hermes.finance_policy_knowledge_organize': 'finance_policy_knowledge_organize',
'task.hermes.risk_rule_discovery': 'risk_clue_collect'
}
function toObject(value) {
@@ -52,6 +80,12 @@ function resolveTaskTypeFromToolName(value) {
if (name.includes('financial_risk_graph')) {
return 'global_risk_scan'
}
if (name.includes('finance_dashboard_snapshot') || name.includes('finance_dashboard')) {
return 'finance_dashboard_snapshot'
}
if (name.includes('digital_employee_reminder') || name.includes('reminder')) {
return 'digital_employee_reminder_scan'
}
if (name.includes('employee_behavior_profile')) {
return 'employee_behavior_profile_scan'
}
@@ -128,6 +162,43 @@ export function resolveWorkRecordTaskType(run) {
return ''
}
export function isVisibleDigitalEmployeeWorkRecord(run) {
const taskType = resolveWorkRecordTaskType(run)
return VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES.has(taskType)
}
function resolveWorkRecordDayKey(run) {
const date = new Date(run?.started_at || run?.finished_at || '')
if (Number.isNaN(date.getTime())) {
return 'unknown'
}
return date.toISOString().slice(0, 10)
}
export function compactDigitalEmployeeWorkRecords(items = []) {
const rows = []
const compactedKeys = new Set()
for (const run of items) {
const taskType = resolveWorkRecordTaskType(run)
if (!VISIBLE_DIGITAL_EMPLOYEE_WORK_TASK_TYPES.has(taskType)) {
continue
}
if (DAILY_COMPACT_TASK_TYPES.has(taskType)) {
const key = `${taskType}:${resolveWorkRecordDayKey(run)}`
if (compactedKeys.has(key)) {
continue
}
compactedKeys.add(key)
}
rows.push(run)
}
return rows
}
export function resolveWorkRecordTaskLabel(run) {
const taskType = resolveWorkRecordTaskType(run)
return TASK_TYPE_LABELS[taskType] || ''
@@ -135,6 +206,12 @@ export function resolveWorkRecordTaskLabel(run) {
export function resolveWorkRecordProductKind(run) {
const taskType = resolveWorkRecordTaskType(run)
if (taskType === 'finance_dashboard_snapshot') {
return 'finance_snapshot'
}
if (taskType === 'digital_employee_reminder_scan') {
return 'reminder_scan'
}
if (taskType === 'global_risk_scan') {
return 'risk_graph'
}

View File

@@ -458,6 +458,7 @@ export function sanitizeRequest(request) {
const normalized = {
claimId: String(request.claimId || request.claim_id || '').trim(),
claimNo: String(request.claimNo || request.claim_no || request.documentNo || '').trim(),
id: String(request.id || '').trim(),
typeLabel: String(request.typeLabel || request.category || '').trim(),
reason: String(request.reason || request.title || '').trim(),
@@ -468,7 +469,8 @@ export function sanitizeRequest(request) {
amount: String(request.amount || '').trim(),
node: String(request.node || '').trim(),
approval: String(request.approval || '').trim(),
travel: String(request.travel || '').trim()
travel: String(request.travel || '').trim(),
applicationEditMode: Boolean(request.applicationEditMode || request.application_edit_mode)
}
return Object.values(normalized).some(Boolean) ? normalized : null

View File

@@ -150,7 +150,7 @@ function resolveRequestBusinessStage(request = {}) {
function normalizeTone(value) {
const tone = normalizeText(value).toLowerCase()
if (tone === 'pass') return 'pass'
if (['pass', 'success', 'ok', 'normal', 'none', 'compliant', 'approved'].includes(tone)) return 'pass'
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
if (tone === 'low') return 'low'

View File

@@ -2,9 +2,13 @@ import { ref } from 'vue'
import {
APPLICATION_TRANSPORT_MODE_OPTIONS,
applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult,
buildApplicationPreviewRows,
buildApplicationPolicyEstimateRequest,
buildLocalApplicationPreviewMessage,
normalizeApplicationPreview,
resolveApplicationDaysFromDateRange,
refreshApplicationPreviewTransportEstimate
} from '../../utils/expenseApplicationPreview.js'
import { waitForMockApplicationTransportQuote } from '../../utils/expenseApplicationEstimate.js'
@@ -44,6 +48,27 @@ function shouldRefreshTransportEstimate(fieldKey) {
return ['transportMode', 'time', 'location', 'days'].includes(fieldKey)
}
function resolveEditorCurrentUser(currentUser) {
if (currentUser && typeof currentUser === 'object' && 'value' in currentUser) {
return currentUser.value || {}
}
return currentUser || {}
}
function buildEditedApplicationPreviewFields(fields = {}, editor = {}, nextValue = '') {
const nextFields = {
...fields,
[editor.fieldKey]: nextValue
}
if (editor.fieldKey === 'time') {
const resolvedDays = resolveApplicationDaysFromDateRange(nextValue)
if (resolvedDays) {
nextFields.days = resolvedDays
}
}
return nextFields
}
function buildTransportEstimatePendingPreview(preview = {}) {
const fields = preview?.fields || {}
return normalizeApplicationPreview({
@@ -57,9 +82,29 @@ function buildTransportEstimatePendingPreview(preview = {}) {
})
}
export function useApplicationPreviewEditor({ persistSessionState, toast } = {}) {
export function useApplicationPreviewEditor({
persistSessionState,
toast,
calculateTravelReimbursement,
currentUser
} = {}) {
const applicationPreviewEditor = ref(buildEmptyEditor())
async function refreshApplicationPreviewEstimate(preview = {}) {
const user = resolveEditorCurrentUser(currentUser)
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
if (estimateRequest.canCalculate && typeof calculateTravelReimbursement === 'function') {
try {
const result = await calculateTravelReimbursement(estimateRequest.payload)
return applyApplicationPolicyEstimateResult(preview, result, user)
} catch (error) {
console.warn('Application preview estimate refresh failed:', error)
return applyApplicationPolicyEstimateError(preview, error, user)
}
}
return refreshApplicationPreviewTransportEstimate(preview)
}
function resolveApplicationPreviewRows(message) {
return buildApplicationPreviewRows(message?.applicationPreview || {})
}
@@ -158,25 +203,29 @@ export function useApplicationPreviewEditor({ persistSessionState, toast } = {})
}
const nextPreview = normalizeApplicationPreview({
...message.applicationPreview,
fields: {
...(message.applicationPreview.fields || {}),
[editor.fieldKey]: nextValue
}
fields: buildEditedApplicationPreviewFields(
message.applicationPreview.fields || {},
editor,
nextValue
)
})
const needRefreshTransport = shouldRefreshTransportEstimate(editor.fieldKey) && String(nextPreview.fields?.transportMode || '').trim()
message.applicationPreview = needRefreshTransport
const needRefreshEstimate = shouldRefreshTransportEstimate(editor.fieldKey)
const transportMode = String(nextPreview.fields?.transportMode || '').trim()
message.applicationPreview = needRefreshEstimate
? 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)
if (needRefreshEstimate) {
if (transportMode) {
await waitForMockApplicationTransportQuote({
transportMode,
location: nextPreview.fields.matchedCity || nextPreview.fields.location,
time: nextPreview.fields.time
})
}
const refreshedPreview = await refreshApplicationPreviewEstimate(nextPreview)
message.applicationPreview = refreshedPreview
message.text = buildLocalApplicationPreviewMessage(refreshedPreview)
persistSessionState?.()

View File

@@ -207,6 +207,7 @@ export function useTravelReimbursementSessionState({
shouldPersistLocalSnapshot
&& props.entrySource !== 'budget'
&& !String(props.initialPrompt || '').trim()
&& !props.initialApplicationPreview
&& !props.initialFiles.length
const persistedInitialSnapshot = readAssistantSessionSnapshot(resolveCurrentUserId(), initialSessionType)
const persistedInitialState = canRestorePersistedInitialState