diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md
index b149a72..3bc9174 100644
--- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md
+++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md
@@ -443,6 +443,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 领域模型迁移:旧 `ReimbursementRequest`、`ExpenseClaim` 和 JSON 状态并存,必须旁路记录、小步迁移和双读校验。
- 认证和租户:当前客户端身份头不适合自动化和 SaaS,多租户、记忆和高风险动作开发前必须修复。
- 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO;当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。
+- 费用事件读取边界:当前查询响应仍包含幂等键、correlation、causation 和 Outbox 投递字段,并在校验入口单据权限后返回整 Case 事件;进入多租户试点前必须改为用户态精简 DTO,并以 HTTP 权限测试冻结关联事件、审批意见、退回原因和操作人的可见范围。
- 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。
- 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。
- 虚假节省:风险暴露金额、暂缓付款和工时估算容易被夸大,必须由客户财务确认并执行去重。
@@ -491,3 +492,6 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 2026-07-13(P0 提交一致性补缝):AI 新建费用申请并直接提交时,先持久化为草稿,再统一委托 `ExpenseClaimService.submit_claim`;提交校验、预算预占、申请提交风险标记、`application_submitted` 事件和审批状态不再由 AI 入口分别维护。
- 2026-07-13(事务边界修复):预算表运行时就绪检查改为复用当前 Session 连接,避免通过 Engine 执行 metadata 检查时隐式提交已 `flush` 的申请;即使预算预占后 Expense Case 事件写入失败,申请、预算额度、预算流水、预占和 Case 数据也会整体回滚。
- 2026-07-13(提交一致性验证):容器内 AI 申请直接提交、失败回滚、保存草稿副作用、申请提交主链路及预算/Expense Case 回归共 21 项通过,`ruff --select F,I` 通过;未修改数据库结构,未对持久化开发数据库执行迁移。
+- 2026-07-13(P0 前端时间线):申请/报销详情在原有横向进度下接入真实 `/api/v1/expense-cases/by-claim/{claim_id}`,新增稳定排序、中文事件语义、状态迁移、退回原因、操作人和发生时间展示;未知事件使用通用文案,不暴露 correlation、幂等键或 Outbox 投递状态。
+- 2026-07-13(前端兼容与验证):单据切换时清空旧时间线并通过请求序列防止乱序覆盖;未纳入 Expense Case 的 404 显示非阻断兼容态,其他接口错误支持重试且不影响原有进度与单据操作。容器内 99 条定向前端测试和 Vite 生产构建通过。
+- 2026-07-13(联调边界):当前持久化开发数据库尚无 `auth_sessions`、`expense_cases`、`expense_case_links` 和 `business_events` 表,浏览器登录无法获得有效认证凭证,因此本轮未声称完成真实页面端到端联调,也未擅自执行数据库迁移。计划、消费/票据、入账、对账和复盘事件仍待后续补齐。
diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md
index e9dd9c0..8cca35e 100644
--- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md
+++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md
@@ -54,6 +54,7 @@
证据:`bootstrap.py`、`settings.py`、`audit_logs.py`、`agent_traces.py`、`system_logs.py`、`vite.config.js`;已初始化 Bootstrap 脱敏且拒绝匿名重配,平台管理员/业务经理权限边界和 Vite Setup 锁测试通过。
- [ ] [CONCEPT: 权限与安全] 继续盘点并收口风险规则发布、制度发布及其他尚未纳入本轮的敏感动作,按动作定义平台管理员或双人复核权限。
- [ ] [CONCEPT: 权限与安全] 为所有新增表和共享核心数据补齐最小 `tenant_id`、数据库约束、查询守卫及默认租户迁移。
+- [ ] [CONCEPT: 权限与安全] 为 Expense Case 查询提供面向用户的精简事件 DTO,移除幂等键、correlation、causation 和 Outbox 投递字段,并明确审批意见、退回原因和操作人可见范围。
- [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。
- [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。
- [x] [CONCEPT: 费用领域与编排] 新增 `ExpenseCaseService` 和费用事件查询接口,保持编排与具体职责分离。
@@ -78,7 +79,9 @@
## 5. P0 前端实现:一键报销与真实移动端
-- [ ] [CONCEPT: 统一费用事件] 新增统一费用事件时间线,串联计划、消费、报销、审批、付款/入账和复盘。
+- [x] [CONCEPT: 统一费用事件] 在申请/报销详情接入已有真实 Expense Case 事件时间线,展示申请提交、报销提交、退回、审批、自动生成报销草稿、付款和归档事件。
+ 证据:`TravelRequestExpenseCaseTimeline.vue`、`useExpenseCaseTimeline.js`、`expenseCases.js`、`expenseCaseTimeline.js`;保留原有横向进度,404 与接口异常均安全降级,容器内 99 条定向前端回归和 Vite 生产构建通过。
+- [ ] [CONCEPT: 统一费用事件] 补齐计划、消费/票据、入账、对账和复盘事件,并在真实迁移数据库上跑通完整时间线。
- [ ] [CONCEPT: 统一费用事件] 自动匹配申请、预算、票据、费用类型、项目、成本中心和常用字段。
- [ ] [CONCEPT: 统一费用事件] 增加“需要确认”区,只展示低置信、缺失或冲突字段。
- [ ] [CONCEPT: 风险与预审] 风险卡提供一键补件、修正、解释和重新预审入口。
@@ -144,13 +147,16 @@
- [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。
证据:`test_auth_session_endpoints.py`、`test_auth_service.py`、`test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。
- [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。
+- [ ] [CONCEPT: 测试方案] 为 Expense Case GET 接口补充 owner、审批人、财务、管理员、无权限用户和整 Case 关联事件可见范围的 HTTP 权限测试。
- [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
- [ ] [CONCEPT: 测试方案] 跑通 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
- [ ] [CONCEPT: 测试方案] 跑通风险反馈 → few-shot → golden case → Canary → 回滚闭环。
- [ ] [CONCEPT: 测试方案] 跑通节省机会 → 执行 → 实现 → 财务确认 → ROI 看板闭环。
-- [ ] [CONCEPT: 测试方案] 补充前端组件、视图模型、键盘操作、响应式、移动真实接口和构建验证。
+- [x] [CONCEPT: 测试方案] 为已有 Expense Case 事件时间线补充视图模型、404 降级、详情页接入及相关响应式回归,并完成前端生产构建。
+ 证据:容器内 `node --test` 定向执行 99 项通过;`npm --prefix web run build` 通过。浏览器可打开本地应用,但当前未迁移数据库无法签发有效认证凭证,真实详情页联调留待迁移后完成。
+- [ ] [CONCEPT: 测试方案] 补充其余前端组件、键盘操作、移动真实接口和完整浏览器关键流程验证。
- [ ] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。
- [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。
diff --git a/web/src/assets/styles/components/travel-request-expense-case-timeline.css b/web/src/assets/styles/components/travel-request-expense-case-timeline.css
new file mode 100644
index 0000000..49834e6
--- /dev/null
+++ b/web/src/assets/styles/components/travel-request-expense-case-timeline.css
@@ -0,0 +1,253 @@
+.expense-case-timeline {
+ min-width: 0;
+ margin-top: 18px;
+ padding-top: 18px;
+ border-top: 1px solid #e2e8f0;
+}
+
+.expense-case-timeline-head,
+.expense-case-timeline-title-row,
+.expense-case-meta,
+.expense-case-event-meta {
+ display: flex;
+ align-items: center;
+}
+
+.expense-case-timeline-head {
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.expense-case-timeline-title-row {
+ flex-wrap: wrap;
+ gap: 9px;
+}
+
+.expense-case-timeline h4 {
+ margin: 0;
+ color: #0f172a;
+ font-size: 14px;
+ font-weight: 850;
+}
+
+.expense-case-timeline-head p {
+ margin: 6px 0 0;
+ color: #64748b;
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.expense-case-stage {
+ padding: 3px 8px;
+ border: 1px solid rgba(var(--theme-primary-rgb), .2);
+ border-radius: 999px;
+ background: var(--theme-primary-soft);
+ color: var(--theme-primary-active);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.expense-case-meta {
+ flex-wrap: wrap;
+ gap: 0;
+}
+
+.expense-case-meta span + span::before {
+ content: "·";
+ margin: 0 7px;
+ color: #cbd5e1;
+}
+
+.expense-case-refresh {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 11px;
+ border: 1px solid #dbe4ee;
+ border-radius: 8px;
+ background: #fff;
+ color: #475569;
+ font: inherit;
+ font-size: 12px;
+ font-weight: 750;
+ cursor: pointer;
+}
+
+.expense-case-refresh:hover:not(:disabled) {
+ border-color: rgba(var(--theme-primary-rgb), .34);
+ color: var(--theme-primary-active);
+}
+
+.expense-case-refresh:disabled {
+ cursor: wait;
+ opacity: .62;
+}
+
+.expense-case-timeline-body {
+ min-height: 58px;
+ margin-top: 15px;
+}
+
+.expense-case-state {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-height: 58px;
+ padding: 12px 14px;
+ border: 1px dashed #cbd5e1;
+ border-radius: 10px;
+ background: #f8fafc;
+ color: #64748b;
+ font-size: 12px;
+}
+
+.expense-case-state > i {
+ flex: 0 0 auto;
+ color: var(--theme-primary);
+ font-size: 21px;
+}
+
+.expense-case-state div {
+ flex: 1 1 auto;
+ min-width: 0;
+ display: grid;
+ gap: 3px;
+}
+
+.expense-case-state strong {
+ color: #334155;
+ font-size: 12px;
+}
+
+.expense-case-state.error {
+ border-color: #fecaca;
+ background: #fff7f7;
+}
+
+.expense-case-state.error > i {
+ color: #dc2626;
+}
+
+.expense-case-state button {
+ flex: 0 0 auto;
+ padding: 6px 9px;
+ border: 0;
+ border-radius: 7px;
+ background: var(--theme-primary);
+ color: #fff;
+ font: inherit;
+ font-size: 11px;
+ font-weight: 800;
+ cursor: pointer;
+}
+
+.expense-case-event-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.expense-case-event {
+ position: relative;
+ display: grid;
+ grid-template-columns: 34px minmax(0, 1fr);
+ gap: 12px;
+ padding: 0 0 18px;
+}
+
+.expense-case-event:last-child {
+ padding-bottom: 0;
+}
+
+.expense-case-event:not(:last-child)::before {
+ content: "";
+ position: absolute;
+ top: 31px;
+ bottom: 2px;
+ left: 16px;
+ width: 2px;
+ background: #e2e8f0;
+}
+
+.expense-case-event-icon {
+ position: relative;
+ z-index: 1;
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ border: 1px solid rgba(var(--theme-primary-rgb), .2);
+ border-radius: 999px;
+ background: var(--theme-primary-soft);
+ color: var(--theme-primary-active);
+ font-size: 17px;
+}
+
+.expense-case-event.success .expense-case-event-icon {
+ border-color: #bbf7d0;
+ background: #f0fdf4;
+ color: #15803d;
+}
+
+.expense-case-event.warning .expense-case-event-icon {
+ border-color: #fed7aa;
+ background: #fff7ed;
+ color: #c2410c;
+}
+
+.expense-case-event-copy {
+ min-width: 0;
+ padding-top: 1px;
+}
+
+.expense-case-event-copy > strong {
+ display: block;
+ color: #1e293b;
+ font-size: 13px;
+ line-height: 1.45;
+}
+
+.expense-case-event-copy > p {
+ margin: 4px 0 7px;
+ color: #475569;
+ font-size: 12px;
+ line-height: 1.55;
+ overflow-wrap: anywhere;
+}
+
+.expense-case-event-meta {
+ flex-wrap: wrap;
+ gap: 6px 14px;
+ color: #94a3b8;
+ font-size: 11px;
+}
+
+.expense-case-event-meta span {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+
+@media (max-width: 720px) {
+ .expense-case-timeline-head {
+ align-items: flex-start;
+ }
+
+ .expense-case-refresh span {
+ display: none;
+ }
+
+ .expense-case-refresh {
+ padding: 7px 9px;
+ }
+
+ .expense-case-state {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ }
+
+ .expense-case-state button {
+ margin-left: 33px;
+ }
+}
diff --git a/web/src/components/travel/TravelRequestExpenseCaseTimeline.vue b/web/src/components/travel/TravelRequestExpenseCaseTimeline.vue
new file mode 100644
index 0000000..9bd0409
--- /dev/null
+++ b/web/src/components/travel/TravelRequestExpenseCaseTimeline.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
费用事件时间线
+ {{ timeline.stageLabel }}
+
+
+ {{ timeline.caseNo || '统一费用事件' }}
+ 已串联 {{ timeline.linkCount }} 张业务单据
+
+
记录费用从申请、审批、报销到付款的真实办理轨迹。
+
+
+
+
+
+
+
+ 正在同步真实费用事件
+
+
+
+
+
+ 费用事件加载失败
+ {{ errorMessage }}
+
+
+
+
+
+
+
+ 当前单据尚未纳入统一费用事件
+ 上方进度仍可正常使用,后续业务动作写入后将自动展示办理记录。
+
+
+
+
+
+
+ 统一费用事件已建立
+ 暂无可展示的办理记录。
+
+
+
+
+ -
+
+
+
+
+
{{ event.label }}
+
{{ event.summary }}
+
+ {{ event.actorLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/src/components/travel/TravelRequestProgressCard.vue b/web/src/components/travel/TravelRequestProgressCard.vue
index a304949..cafaa4c 100644
--- a/web/src/components/travel/TravelRequestProgressCard.vue
+++ b/web/src/components/travel/TravelRequestProgressCard.vue
@@ -31,14 +31,23 @@
+
diff --git a/web/src/composables/requests/useExpenseCaseTimeline.js b/web/src/composables/requests/useExpenseCaseTimeline.js
new file mode 100644
index 0000000..f9835d1
--- /dev/null
+++ b/web/src/composables/requests/useExpenseCaseTimeline.js
@@ -0,0 +1,91 @@
+import { onScopeDispose, ref, unref, watch } from 'vue'
+
+import { fetchExpenseCaseTimeline } from '../../services/expenseCases.js'
+import {
+ buildExpenseCaseTimelineViewModel,
+ isExpenseCaseNotAvailableError
+} from '../../utils/expenseCaseTimeline.js'
+
+export function useExpenseCaseTimeline({
+ claimId,
+ refreshKey,
+ fetchTimeline = fetchExpenseCaseTimeline
+}) {
+ const timeline = ref(null)
+ const loading = ref(false)
+ const errorMessage = ref('')
+ const notAvailable = ref(false)
+ let loadSequence = 0
+ let disposed = false
+
+ function resetTimelineState() {
+ timeline.value = null
+ loading.value = false
+ errorMessage.value = ''
+ notAvailable.value = false
+ }
+
+ async function loadTimeline() {
+ const normalizedClaimId = String(unref(claimId) || '').trim()
+ const sequence = ++loadSequence
+
+ if (!normalizedClaimId) {
+ resetTimelineState()
+ return
+ }
+
+ timeline.value = null
+ loading.value = true
+ errorMessage.value = ''
+ notAvailable.value = false
+
+ try {
+ const payload = await fetchTimeline(normalizedClaimId, {
+ timeoutMs: 10000,
+ timeoutMessage: '费用事件加载超时,请稍后重试。'
+ })
+ if (disposed || sequence !== loadSequence) {
+ return
+ }
+ timeline.value = buildExpenseCaseTimelineViewModel(payload || {})
+ } catch (error) {
+ if (disposed || sequence !== loadSequence) {
+ return
+ }
+
+ timeline.value = null
+ if (isExpenseCaseNotAvailableError(error)) {
+ notAvailable.value = true
+ return
+ }
+
+ errorMessage.value = '费用事件暂时加载失败,上方进度与单据操作不受影响。'
+ } finally {
+ if (!disposed && sequence === loadSequence) {
+ loading.value = false
+ }
+ }
+ }
+
+ watch(
+ [
+ () => String(unref(claimId) || '').trim(),
+ () => String(unref(refreshKey) || '')
+ ],
+ loadTimeline,
+ { immediate: true }
+ )
+
+ onScopeDispose(() => {
+ disposed = true
+ loadSequence += 1
+ })
+
+ return {
+ timeline,
+ loading,
+ errorMessage,
+ notAvailable,
+ refreshExpenseCaseTimeline: loadTimeline
+ }
+}
diff --git a/web/src/services/expenseCases.js b/web/src/services/expenseCases.js
new file mode 100644
index 0000000..8780a24
--- /dev/null
+++ b/web/src/services/expenseCases.js
@@ -0,0 +1,10 @@
+import { apiRequest } from './api.js'
+
+export function fetchExpenseCaseTimeline(claimId, options = {}) {
+ const normalizedClaimId = String(claimId || '').trim()
+ if (!normalizedClaimId) {
+ return Promise.resolve(null)
+ }
+
+ return apiRequest(`/expense-cases/by-claim/${encodeURIComponent(normalizedClaimId)}`, options)
+}
diff --git a/web/src/utils/expenseCaseTimeline.js b/web/src/utils/expenseCaseTimeline.js
new file mode 100644
index 0000000..1fa31e5
--- /dev/null
+++ b/web/src/utils/expenseCaseTimeline.js
@@ -0,0 +1,194 @@
+const CASE_STAGE_LABELS = {
+ planning: '申请规划',
+ approved_to_spend: '已批准支出',
+ spending: '消费中',
+ claiming: '填报中',
+ reviewing: '审批中',
+ paying: '付款中',
+ accounting: '已付款 / 待入账',
+ closed: '已结束',
+ cancelled: '已取消'
+}
+
+const BUSINESS_STATUS_LABELS = {
+ draft: '草稿',
+ submitted: '已提交',
+ returned: '已退回',
+ approved: '已审批',
+ pending_payment: '待付款',
+ paid: '已付款',
+ archived: '已归档',
+ active: '进行中',
+ closed: '已关闭',
+ cancelled: '已取消'
+}
+
+const EVENT_PRESENTATION = {
+ claim_draft_created: { label: '报销草稿已创建', icon: 'mdi mdi-file-plus-outline', tone: 'info' },
+ claim_draft_updated: { label: '报销草稿已更新', icon: 'mdi mdi-file-edit-outline', tone: 'info' },
+ application_submitted: { label: '费用申请已提交', icon: 'mdi mdi-send-outline', tone: 'info' },
+ claim_submitted: { label: '报销单已提交', icon: 'mdi mdi-send-outline', tone: 'info' },
+ approval_stage_completed: { label: '审批节点已完成', icon: 'mdi mdi-account-check-outline', tone: 'success' },
+ application_approved: { label: '费用申请已审批', icon: 'mdi mdi-check-decagram-outline', tone: 'success' },
+ claim_approved: { label: '报销单已审批', icon: 'mdi mdi-check-decagram-outline', tone: 'success' },
+ application_returned: { label: '费用申请被退回', icon: 'mdi mdi-arrow-u-left-top', tone: 'warning' },
+ claim_returned: { label: '报销单被退回', icon: 'mdi mdi-arrow-u-left-top', tone: 'warning' },
+ reimbursement_draft_generated: { label: '报销草稿已自动生成', icon: 'mdi mdi-robot-outline', tone: 'success' },
+ payment_completed: { label: '付款已完成', icon: 'mdi mdi-cash-check', tone: 'success' },
+ application_archived: { label: '费用申请已归档', icon: 'mdi mdi-archive-check-outline', tone: 'success' }
+}
+
+const FALLBACK_EVENT_PRESENTATION = {
+ label: '流程状态已更新',
+ icon: 'mdi mdi-timeline-clock-outline',
+ tone: 'info'
+}
+
+const EVENT_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false
+})
+
+function compactText(value) {
+ return String(value ?? '').trim()
+}
+
+function readField(source, snakeKey, camelKey) {
+ return source?.[snakeKey] ?? source?.[camelKey]
+}
+
+function resolveStatusLabel(value) {
+ const normalized = compactText(value)
+ return BUSINESS_STATUS_LABELS[normalized] || normalized
+}
+
+function buildStateTransitionSummary(payload) {
+ const previousStatus = resolveStatusLabel(readField(payload, 'previous_status', 'previousStatus'))
+ const nextStatus = resolveStatusLabel(readField(payload, 'next_status', 'nextStatus'))
+ const nextStage = compactText(readField(payload, 'next_approval_stage', 'nextApprovalStage'))
+ const transition = previousStatus && nextStatus
+ ? `${previousStatus} → ${nextStatus}`
+ : nextStatus
+ ? `状态更新为${nextStatus}`
+ : ''
+
+ if (transition && nextStage) {
+ return `${transition} · 当前节点:${nextStage}`
+ }
+
+ return transition || (nextStage ? `当前节点:${nextStage}` : '')
+}
+
+function buildEventSummary(eventType, payload) {
+ const reason = compactText(payload?.reason)
+ if (reason) {
+ return reason
+ }
+
+ const opinion = compactText(payload?.opinion)
+ if (opinion) {
+ return opinion
+ }
+
+ if (eventType === 'reimbursement_draft_generated') {
+ const applicationClaimNo = compactText(readField(payload, 'application_claim_no', 'applicationClaimNo'))
+ if (applicationClaimNo) {
+ return `已由费用申请 ${applicationClaimNo} 自动生成报销草稿。`
+ }
+ }
+
+ if (eventType === 'application_archived') {
+ const reimbursementClaimNo = compactText(readField(payload, 'reimbursement_claim_no', 'reimbursementClaimNo'))
+ if (reimbursementClaimNo) {
+ return `关联报销单 ${reimbursementClaimNo} 已付款,本费用申请已归档。`
+ }
+ }
+
+ if (eventType === 'payment_completed') {
+ const archivedApplications = readField(payload, 'archived_applications', 'archivedApplications')
+ const archivedCount = Array.isArray(archivedApplications)
+ ? archivedApplications.length
+ : 0
+ if (archivedCount > 0) {
+ return `付款完成,并同步归档 ${archivedCount} 张关联费用申请。`
+ }
+ }
+
+ return buildStateTransitionSummary(payload) || '业务状态已更新。'
+}
+
+function formatOccurredAt(value) {
+ const normalized = compactText(value)
+ if (!normalized) {
+ return '时间待同步'
+ }
+
+ const date = new Date(normalized)
+ if (Number.isNaN(date.getTime())) {
+ return normalized
+ }
+
+ return EVENT_DATE_TIME_FORMATTER.format(date).replaceAll('/', '-')
+}
+
+function resolveActorLabel(event) {
+ const actorId = compactText(readField(event, 'actor_id', 'actorId'))
+ const actorType = compactText(readField(event, 'actor_type', 'actorType')).toLowerCase()
+ if (actorType === 'system' || actorId.toLowerCase() === 'system') {
+ return '系统'
+ }
+
+ return actorId || '操作人待同步'
+}
+
+function normalizeEvent(event, index) {
+ const eventType = compactText(readField(event, 'event_type', 'eventType'))
+ const presentation = EVENT_PRESENTATION[eventType] || FALLBACK_EVENT_PRESENTATION
+ const occurredAt = readField(event, 'occurred_at', 'occurredAt')
+ const timestamp = new Date(occurredAt || '').getTime()
+ const payload = readField(event, 'payload_json', 'payloadJson') || {}
+
+ return {
+ id: compactText(event?.id) || `${eventType || 'event'}-${index}`,
+ eventType,
+ label: presentation.label,
+ icon: presentation.icon,
+ tone: presentation.tone,
+ summary: buildEventSummary(eventType, payload),
+ actorLabel: resolveActorLabel(event),
+ occurredAt: compactText(occurredAt),
+ occurredAtLabel: formatOccurredAt(occurredAt),
+ sortTimestamp: Number.isNaN(timestamp) ? null : timestamp,
+ sourceIndex: index
+ }
+}
+
+export function buildExpenseCaseTimelineViewModel(payload = {}) {
+ const events = Array.isArray(payload?.events)
+ ? payload.events.map(normalizeEvent).sort((left, right) => {
+ if (left.sortTimestamp !== null && right.sortTimestamp !== null) {
+ return left.sortTimestamp - right.sortTimestamp || left.sourceIndex - right.sourceIndex
+ }
+ return left.sourceIndex - right.sourceIndex
+ })
+ : []
+
+ return {
+ caseId: compactText(payload?.id),
+ caseNo: compactText(readField(payload, 'case_no', 'caseNo')),
+ stage: compactText(readField(payload, 'current_stage', 'currentStage')),
+ stageLabel: CASE_STAGE_LABELS[compactText(readField(payload, 'current_stage', 'currentStage'))] || '流程跟进中',
+ status: compactText(payload?.status),
+ statusLabel: resolveStatusLabel(payload?.status) || '状态待同步',
+ linkCount: Array.isArray(payload?.links) ? payload.links.length : 0,
+ events
+ }
+}
+
+export function isExpenseCaseNotAvailableError(error) {
+ return Number(error?.status) === 404
+}
diff --git a/web/src/views/TravelRequestDetailView.vue b/web/src/views/TravelRequestDetailView.vue
index 9f34626..736dd88 100644
--- a/web/src/views/TravelRequestDetailView.vue
+++ b/web/src/views/TravelRequestDetailView.vue
@@ -7,6 +7,8 @@
:is-application-document="isApplicationDocument"
:progress-steps="progressSteps"
:current-progress-ring-motion="currentProgressRingMotion"
+ :claim-id="request.claimId"
+ :refresh-key="expenseCaseTimelineRefreshKey"
/>
diff --git a/web/src/views/scripts/travelRequestDetailSetup.js b/web/src/views/scripts/travelRequestDetailSetup.js
index 1b034a4..2d55e18 100644
--- a/web/src/views/scripts/travelRequestDetailSetup.js
+++ b/web/src/views/scripts/travelRequestDetailSetup.js
@@ -347,6 +347,12 @@ export function useTravelRequestDetailSetup(props, { emit }) {
isCurrentDirectManagerApprover: isCurrentDirectManagerApprover.value
})
})
+ const expenseCaseTimelineRefreshKey = computed(() => [
+ request.value.claimId,
+ request.value.status,
+ request.value.node,
+ request.value.updatedAt || request.value.updated_at
+ ].map((value) => String(value || '').trim()).join('|'))
const currentProgressRingMotion = {
initial: { scale: 1, opacity: 0.34 },
enter: {
@@ -638,6 +644,7 @@ export function useTravelRequestDetailSetup(props, { emit }) {
canApproveRequest,
canReturnRequest,
currentProgressRingMotion,
+ expenseCaseTimelineRefreshKey,
expenseItems,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
hasLeaderApprovalEvents,
diff --git a/web/tests/expense-case-timeline.test.mjs b/web/tests/expense-case-timeline.test.mjs
new file mode 100644
index 0000000..90befb7
--- /dev/null
+++ b/web/tests/expense-case-timeline.test.mjs
@@ -0,0 +1,257 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+import { fileURLToPath } from 'node:url'
+import { effectScope, nextTick, ref } from 'vue'
+
+import {
+ buildExpenseCaseTimelineViewModel,
+ isExpenseCaseNotAvailableError
+} from '../src/utils/expenseCaseTimeline.js'
+import { fetchExpenseCaseTimeline } from '../src/services/expenseCases.js'
+import { useExpenseCaseTimeline } from '../src/composables/requests/useExpenseCaseTimeline.js'
+
+function createDeferred() {
+ let resolve
+ let reject
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise
+ reject = rejectPromise
+ })
+ return { promise, resolve, reject }
+}
+
+async function flushTimelineUpdates() {
+ await Promise.resolve()
+ await nextTick()
+ await Promise.resolve()
+}
+
+test('expense case timeline maps and sorts the real business event chain', () => {
+ const timeline = buildExpenseCaseTimelineViewModel({
+ id: 'case-1',
+ case_no: 'CASE-20260713-001',
+ current_stage: 'reviewing',
+ status: 'active',
+ links: [
+ { id: 'link-1', relation_type: 'application', resource_id: 'application-1' },
+ { id: 'link-2', relation_type: 'generated_reimbursement', resource_id: 'claim-1' }
+ ],
+ events: [
+ {
+ id: 'event-2',
+ event_type: 'reimbursement_draft_generated',
+ actor_id: 'system',
+ occurred_at: '2026-07-13T02:20:00Z',
+ payload_json: {
+ application_claim_no: 'AP-20260713-001',
+ next_status: 'draft',
+ next_approval_stage: '待提交'
+ }
+ },
+ {
+ id: 'event-1',
+ event_type: 'application_submitted',
+ actor_id: 'employee@example.com',
+ occurred_at: '2026-07-13T01:10:00Z',
+ payload_json: {
+ claim_no: 'AP-20260713-001',
+ previous_status: 'draft',
+ next_status: 'submitted',
+ next_approval_stage: '直属领导审批'
+ }
+ }
+ ]
+ })
+
+ assert.equal(timeline.caseNo, 'CASE-20260713-001')
+ assert.equal(timeline.stageLabel, '审批中')
+ assert.equal(timeline.linkCount, 2)
+ assert.deepEqual(timeline.events.map((event) => event.id), ['event-1', 'event-2'])
+ assert.equal(timeline.events[0].label, '费用申请已提交')
+ assert.match(timeline.events[0].summary, /草稿 → 已提交/)
+ assert.match(timeline.events[0].summary, /直属领导审批/)
+ assert.equal(timeline.events[0].actorLabel, 'employee@example.com')
+ assert.equal(timeline.events[1].label, '报销草稿已自动生成')
+ assert.match(timeline.events[1].summary, /AP-20260713-001/)
+ assert.equal(timeline.events[1].actorLabel, '系统')
+})
+
+test('expense case timeline gives returned events an actionable reason', () => {
+ const timeline = buildExpenseCaseTimelineViewModel({
+ current_stage: 'claiming',
+ events: [
+ {
+ id: 'returned-1',
+ event_type: 'claim_returned',
+ actor_id: 'finance@example.com',
+ occurred_at: '2026-07-13T03:00:00Z',
+ payload_json: {
+ reason: '住宿票据缺少入住人信息',
+ previous_status: 'submitted',
+ next_status: 'returned'
+ }
+ }
+ ]
+ })
+
+ assert.equal(timeline.events[0].label, '报销单被退回')
+ assert.equal(timeline.events[0].tone, 'warning')
+ assert.equal(timeline.events[0].summary, '住宿票据缺少入住人信息')
+})
+
+test('expense case timeline describes application archive against the paid reimbursement', () => {
+ const timeline = buildExpenseCaseTimelineViewModel({
+ events: [
+ {
+ id: 'archived-1',
+ event_type: 'application_archived',
+ occurred_at: '2026-07-13T03:00:00Z',
+ payload_json: { reimbursement_claim_no: 'BX-20260713-001' }
+ }
+ ]
+ })
+
+ assert.equal(
+ timeline.events[0].summary,
+ '关联报销单 BX-20260713-001 已付款,本费用申请已归档。'
+ )
+})
+
+test('expense case timeline treats 404 as a non-blocking not-yet-covered state', () => {
+ assert.equal(isExpenseCaseNotAvailableError({ status: 404 }), true)
+ assert.equal(isExpenseCaseNotAvailableError({ status: 500 }), false)
+})
+
+test('expense case timeline keeps equal-time server order and hides unknown event internals', () => {
+ const timeline = buildExpenseCaseTimelineViewModel({
+ events: [
+ {
+ id: 'unknown-1',
+ event_type: 'connector_internal_delivery_changed',
+ occurred_at: '2026-07-13T03:00:00Z',
+ correlation_id: 'private-correlation',
+ delivery_status: 'pending'
+ },
+ {
+ id: 'known-2',
+ event_type: 'payment_completed',
+ occurred_at: '2026-07-13T03:00:00Z'
+ }
+ ]
+ })
+
+ assert.deepEqual(timeline.events.map((event) => event.id), ['unknown-1', 'known-2'])
+ assert.equal(timeline.events[0].label, '流程状态已更新')
+ assert.doesNotMatch(JSON.stringify(timeline.events[0]), /private-correlation|pending/)
+})
+
+test('expense case service safely encodes claim identifiers', async () => {
+ const originalFetch = globalThis.fetch
+ let requestedUrl = ''
+ globalThis.fetch = async (url) => {
+ requestedUrl = String(url)
+ return {
+ ok: true,
+ status: 200,
+ json: async () => ({ events: [] })
+ }
+ }
+
+ try {
+ await fetchExpenseCaseTimeline(' claim/with space ')
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+
+ assert.equal(requestedUrl, '/api/v1/expense-cases/by-claim/claim%2Fwith%20space')
+})
+
+test('expense case composable ignores a stale response after switching claims', async () => {
+ const claimId = ref('claim-a')
+ const refreshKey = ref('claim-a|submitted')
+ const requests = new Map([
+ ['claim-a', createDeferred()],
+ ['claim-b', createDeferred()]
+ ])
+ const scope = effectScope()
+ let state
+
+ scope.run(() => {
+ state = useExpenseCaseTimeline({
+ claimId,
+ refreshKey,
+ fetchTimeline: (nextClaimId) => requests.get(nextClaimId).promise
+ })
+ })
+
+ claimId.value = 'claim-b'
+ refreshKey.value = 'claim-b|approved'
+ await nextTick()
+ requests.get('claim-b').resolve({ case_no: 'CASE-B', events: [] })
+ await flushTimelineUpdates()
+ assert.equal(state.timeline.value.caseNo, 'CASE-B')
+
+ requests.get('claim-a').resolve({ case_no: 'CASE-A', events: [] })
+ await flushTimelineUpdates()
+ assert.equal(state.timeline.value.caseNo, 'CASE-B')
+ scope.stop()
+})
+
+test('expense case composable separates 404 compatibility state from retryable errors', async () => {
+ const claimId = ref('claim-a')
+ const refreshKey = ref('first')
+ const scope = effectScope()
+ let attempt = 0
+ let state
+
+ scope.run(() => {
+ state = useExpenseCaseTimeline({
+ claimId,
+ refreshKey,
+ fetchTimeline: async () => {
+ attempt += 1
+ const error = new Error('request failed')
+ error.status = attempt === 1 ? 404 : 500
+ throw error
+ }
+ })
+ })
+
+ await flushTimelineUpdates()
+ assert.equal(state.notAvailable.value, true)
+ assert.equal(state.errorMessage.value, '')
+
+ refreshKey.value = 'retry'
+ await flushTimelineUpdates()
+ assert.equal(state.notAvailable.value, false)
+ assert.match(state.errorMessage.value, /不受影响/)
+ scope.stop()
+})
+
+test('travel request detail consumes the real expense case endpoint', () => {
+ const detailView = readFileSync(
+ fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
+ 'utf8'
+ )
+ const progressCard = readFileSync(
+ fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
+ 'utf8'
+ )
+ const timelineComponent = readFileSync(
+ fileURLToPath(new URL('../src/components/travel/TravelRequestExpenseCaseTimeline.vue', import.meta.url)),
+ 'utf8'
+ )
+ const expenseCaseService = readFileSync(
+ fileURLToPath(new URL('../src/services/expenseCases.js', import.meta.url)),
+ 'utf8'
+ )
+
+ assert.match(detailView, /:claim-id="request\.claimId"/)
+ assert.match(detailView, /:refresh-key="expenseCaseTimelineRefreshKey"/)
+ assert.match(progressCard, / {
- assert.match(detailTemplate, /isApplicationDocument \? '申请进度' : '报销进度'/)
- assert.doesNotMatch(detailTemplate, /差旅进度/)
+ assert.match(progressTemplate, /isApplicationDocument \? '申请进度' : '报销进度'/)
+ assert.doesNotMatch(progressTemplate, /差旅进度/)
assert.match(detailStyles, /\.progress-step-status \{[\s\S]*width:\s*100%;[\s\S]*max-width:\s*136px;[\s\S]*min-width:\s*0;/)
assert.match(detailStyles, /\.progress-step-status \{[\s\S]*white-space:\s*nowrap;[\s\S]*overflow:\s*hidden;[\s\S]*text-overflow:\s*ellipsis;/)
})