feat(expenses): show unified expense event timeline

This commit is contained in:
caoxiaozhu
2026-07-13 16:58:51 +08:00
parent a616b30cb2
commit 22669a9071
12 changed files with 944 additions and 7 deletions

View File

@@ -443,6 +443,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 领域模型迁移:旧 `ReimbursementRequest``ExpenseClaim` 和 JSON 状态并存,必须旁路记录、小步迁移和双读校验。 - 领域模型迁移:旧 `ReimbursementRequest``ExpenseClaim` 和 JSON 状态并存,必须旁路记录、小步迁移和双读校验。
- 认证和租户:当前客户端身份头不适合自动化和 SaaS多租户、记忆和高风险动作开发前必须修复。 - 认证和租户:当前客户端身份头不适合自动化和 SaaS多租户、记忆和高风险动作开发前必须修复。
- 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。 - 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。
- 费用事件读取边界当前查询响应仍包含幂等键、correlation、causation 和 Outbox 投递字段,并在校验入口单据权限后返回整 Case 事件;进入多租户试点前必须改为用户态精简 DTO并以 HTTP 权限测试冻结关联事件、审批意见、退回原因和操作人的可见范围。
- 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。 - 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。
- 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。 - 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。
- 虚假节省:风险暴露金额、暂缓付款和工时估算容易被夸大,必须由客户财务确认并执行去重。 - 虚假节省:风险暴露金额、暂缓付款和工时估算容易被夸大,必须由客户财务确认并执行去重。
@@ -491,3 +492,6 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 2026-07-13P0 提交一致性补缝AI 新建费用申请并直接提交时,先持久化为草稿,再统一委托 `ExpenseClaimService.submit_claim`;提交校验、预算预占、申请提交风险标记、`application_submitted` 事件和审批状态不再由 AI 入口分别维护。 - 2026-07-13P0 提交一致性补缝AI 新建费用申请并直接提交时,先持久化为草稿,再统一委托 `ExpenseClaimService.submit_claim`;提交校验、预算预占、申请提交风险标记、`application_submitted` 事件和审批状态不再由 AI 入口分别维护。
- 2026-07-13事务边界修复预算表运行时就绪检查改为复用当前 Session 连接,避免通过 Engine 执行 metadata 检查时隐式提交已 `flush` 的申请;即使预算预占后 Expense Case 事件写入失败,申请、预算额度、预算流水、预占和 Case 数据也会整体回滚。 - 2026-07-13事务边界修复预算表运行时就绪检查改为复用当前 Session 连接,避免通过 Engine 执行 metadata 检查时隐式提交已 `flush` 的申请;即使预算预占后 Expense Case 事件写入失败,申请、预算额度、预算流水、预占和 Case 数据也会整体回滚。
- 2026-07-13提交一致性验证容器内 AI 申请直接提交、失败回滚、保存草稿副作用、申请提交主链路及预算/Expense Case 回归共 21 项通过,`ruff --select F,I` 通过;未修改数据库结构,未对持久化开发数据库执行迁移。 - 2026-07-13提交一致性验证容器内 AI 申请直接提交、失败回滚、保存草稿副作用、申请提交主链路及预算/Expense Case 回归共 21 项通过,`ruff --select F,I` 通过;未修改数据库结构,未对持久化开发数据库执行迁移。
- 2026-07-13P0 前端时间线):申请/报销详情在原有横向进度下接入真实 `/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` 表,浏览器登录无法获得有效认证凭证,因此本轮未声称完成真实页面端到端联调,也未擅自执行数据库迁移。计划、消费/票据、入账、对账和复盘事件仍待后续补齐。

View File

@@ -54,6 +54,7 @@
证据:`bootstrap.py``settings.py``audit_logs.py``agent_traces.py``system_logs.py``vite.config.js`;已初始化 Bootstrap 脱敏且拒绝匿名重配,平台管理员/业务经理权限边界和 Vite Setup 锁测试通过。 证据:`bootstrap.py``settings.py``audit_logs.py``agent_traces.py``system_logs.py``vite.config.js`;已初始化 Bootstrap 脱敏且拒绝匿名重配,平台管理员/业务经理权限边界和 Vite Setup 锁测试通过。
- [ ] [CONCEPT: 权限与安全] 继续盘点并收口风险规则发布、制度发布及其他尚未纳入本轮的敏感动作,按动作定义平台管理员或双人复核权限。 - [ ] [CONCEPT: 权限与安全] 继续盘点并收口风险规则发布、制度发布及其他尚未纳入本轮的敏感动作,按动作定义平台管理员或双人复核权限。
- [ ] [CONCEPT: 权限与安全] 为所有新增表和共享核心数据补齐最小 `tenant_id`、数据库约束、查询守卫及默认租户迁移。 - [ ] [CONCEPT: 权限与安全] 为所有新增表和共享核心数据补齐最小 `tenant_id`、数据库约束、查询守卫及默认租户迁移。
- [ ] [CONCEPT: 权限与安全] 为 Expense Case 查询提供面向用户的精简事件 DTO移除幂等键、correlation、causation 和 Outbox 投递字段,并明确审批意见、退回原因和操作人可见范围。
- [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。 - [ ] [CONCEPT: 权限与安全] 为 Qdrant collection/namespace、对象存储前缀和缓存键补齐租户隔离回归测试。
- [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。 - [ ] [CONCEPT: 数据与契约] 建立 Alembic baseline 和正式迁移链,停止请求路径运行 DDL。
- [x] [CONCEPT: 费用领域与编排] 新增 `ExpenseCaseService` 和费用事件查询接口,保持编排与具体职责分离。 - [x] [CONCEPT: 费用领域与编排] 新增 `ExpenseCaseService` 和费用事件查询接口,保持编排与具体职责分离。
@@ -78,7 +79,9 @@
## 5. P0 前端实现:一键报销与真实移动端 ## 5. P0 前端实现:一键报销与真实移动端
- [ ] [CONCEPT: 统一费用事件] 新增统一费用事件时间线,串联计划、消费、报销、审批、付款/入账和复盘 - [x] [CONCEPT: 统一费用事件] 在申请/报销详情接入已有真实 Expense Case 事件时间线,展示申请提交、报销提交、退回、审批、自动生成报销草稿、付款和归档事件
证据:`TravelRequestExpenseCaseTimeline.vue``useExpenseCaseTimeline.js``expenseCases.js``expenseCaseTimeline.js`保留原有横向进度404 与接口异常均安全降级,容器内 99 条定向前端回归和 Vite 生产构建通过。
- [ ] [CONCEPT: 统一费用事件] 补齐计划、消费/票据、入账、对账和复盘事件,并在真实迁移数据库上跑通完整时间线。
- [ ] [CONCEPT: 统一费用事件] 自动匹配申请、预算、票据、费用类型、项目、成本中心和常用字段。 - [ ] [CONCEPT: 统一费用事件] 自动匹配申请、预算、票据、费用类型、项目、成本中心和常用字段。
- [ ] [CONCEPT: 统一费用事件] 增加“需要确认”区,只展示低置信、缺失或冲突字段。 - [ ] [CONCEPT: 统一费用事件] 增加“需要确认”区,只展示低置信、缺失或冲突字段。
- [ ] [CONCEPT: 风险与预审] 风险卡提供一键补件、修正、解释和重新预审入口。 - [ ] [CONCEPT: 风险与预审] 风险卡提供一键补件、修正、解释和重新预审入口。
@@ -144,13 +147,16 @@
- [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。 - [x] [CONCEPT: 测试方案] 为服务端会话、管理员保护和 Bootstrap 重配置补充首批安全回归测试。
证据:`test_auth_session_endpoints.py``test_auth_service.py``test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。 证据:`test_auth_session_endpoints.py``test_auth_service.py``test_bootstrap_security.py`;容器定向测试覆盖 token 摘要、伪造身份头、过期/撤销、登出原子收尾、业务经理越权和初始化后匿名重配置拒绝。
- [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。 - [ ] [CONCEPT: 测试方案] 为租户隔离、跨租户访问、规则/制度发布和双人复核等剩余敏感动作补充安全回归测试。
- [ ] [CONCEPT: 测试方案] 为 Expense Case GET 接口补充 owner、审批人、财务、管理员、无权限用户和整 Case 关联事件可见范围的 HTTP 权限测试。
- [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。 - [ ] [CONCEPT: 测试方案] 为 Alembic baseline、升级、旧数据迁移和回滚边界补充 Postgres 集成测试。
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。 - [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。 - [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
- [ ] [CONCEPT: 测试方案] 跑通 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。 - [ ] [CONCEPT: 测试方案] 跑通 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
- [ ] [CONCEPT: 测试方案] 跑通风险反馈 → few-shot → golden case → Canary → 回滚闭环。 - [ ] [CONCEPT: 测试方案] 跑通风险反馈 → few-shot → golden case → Canary → 回滚闭环。
- [ ] [CONCEPT: 测试方案] 跑通节省机会 → 执行 → 实现 → 财务确认 → ROI 看板闭环。 - [ ] [CONCEPT: 测试方案] 跑通节省机会 → 执行 → 实现 → 财务确认 → ROI 看板闭环。
- [ ] [CONCEPT: 测试方案] 补充前端组件、视图模型、键盘操作、响应式、移动真实接口和构建验证 - [x] [CONCEPT: 测试方案] 为已有 Expense Case 事件时间线补充视图模型、404 降级、详情页接入及相关响应式回归,并完成前端生产构建
证据:容器内 `node --test` 定向执行 99 项通过;`npm --prefix web run build` 通过。浏览器可打开本地应用,但当前未迁移数据库无法签发有效认证凭证,真实详情页联调留待迁移后完成。
- [ ] [CONCEPT: 测试方案] 补充其余前端组件、键盘操作、移动真实接口和完整浏览器关键流程验证。
- [ ] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。 - [ ] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。
- [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。 - [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。

View File

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

View File

@@ -0,0 +1,104 @@
<template>
<section class="expense-case-timeline" aria-label="费用事件时间线">
<div class="expense-case-timeline-head">
<div>
<div class="expense-case-timeline-title-row">
<h4>费用事件时间线</h4>
<span v-if="timeline" class="expense-case-stage">{{ timeline.stageLabel }}</span>
</div>
<p v-if="timeline" class="expense-case-meta">
<span>{{ timeline.caseNo || '统一费用事件' }}</span>
<span>已串联 {{ timeline.linkCount }} 张业务单据</span>
</p>
<p v-else>记录费用从申请审批报销到付款的真实办理轨迹</p>
</div>
<button
class="expense-case-refresh"
type="button"
:disabled="loading"
aria-label="刷新费用事件时间线"
@click="refreshExpenseCaseTimeline"
>
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-refresh'"></i>
<span>刷新</span>
</button>
</div>
<div class="expense-case-timeline-body" aria-live="polite">
<div v-if="loading && !timeline" class="expense-case-state loading">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在同步真实费用事件</span>
</div>
<div v-else-if="errorMessage" class="expense-case-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<div>
<strong>费用事件加载失败</strong>
<span>{{ errorMessage }}</span>
</div>
<button type="button" @click="refreshExpenseCaseTimeline">重新加载</button>
</div>
<div v-else-if="notAvailable" class="expense-case-state empty">
<i class="mdi mdi-timeline-alert-outline"></i>
<div>
<strong>当前单据尚未纳入统一费用事件</strong>
<span>上方进度仍可正常使用后续业务动作写入后将自动展示办理记录</span>
</div>
</div>
<div v-else-if="timeline && !timeline.events.length" class="expense-case-state empty">
<i class="mdi mdi-timeline-clock-outline"></i>
<div>
<strong>统一费用事件已建立</strong>
<span>暂无可展示的办理记录</span>
</div>
</div>
<ol v-else-if="timeline" class="expense-case-event-list">
<li
v-for="event in timeline.events"
:key="event.id"
class="expense-case-event"
:class="event.tone"
>
<span class="expense-case-event-icon" aria-hidden="true">
<i :class="event.icon"></i>
</span>
<div class="expense-case-event-copy">
<strong>{{ event.label }}</strong>
<p>{{ event.summary }}</p>
<div class="expense-case-event-meta">
<span><i class="mdi mdi-account-outline"></i>{{ event.actorLabel }}</span>
<time :datetime="event.occurredAt || undefined">{{ event.occurredAtLabel }}</time>
</div>
</div>
</li>
</ol>
</div>
</section>
</template>
<script setup>
import { toRef } from 'vue'
import { useExpenseCaseTimeline } from '../../composables/requests/useExpenseCaseTimeline.js'
const props = defineProps({
claimId: { type: String, default: '' },
refreshKey: { type: String, default: '' }
})
const {
timeline,
loading,
errorMessage,
notAvailable,
refreshExpenseCaseTimeline
} = useExpenseCaseTimeline({
claimId: toRef(props, 'claimId'),
refreshKey: toRef(props, 'refreshKey')
})
</script>
<style scoped src="../../assets/styles/components/travel-request-expense-case-timeline.css"></style>

View File

@@ -31,14 +31,23 @@
</div> </div>
</div> </div>
</div> </div>
<TravelRequestExpenseCaseTimeline
v-if="claimId"
:claim-id="claimId"
:refresh-key="refreshKey"
/>
</article> </article>
</template> </template>
<script setup> <script setup>
import TravelRequestExpenseCaseTimeline from './TravelRequestExpenseCaseTimeline.vue'
defineProps({ defineProps({
isApplicationDocument: { type: Boolean, default: false }, isApplicationDocument: { type: Boolean, default: false },
progressSteps: { type: Array, default: () => [] }, progressSteps: { type: Array, default: () => [] },
currentProgressRingMotion: { type: Object, required: true } currentProgressRingMotion: { type: Object, required: true },
claimId: { type: String, default: '' },
refreshKey: { type: String, default: '' }
}) })
</script> </script>

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,8 @@
:is-application-document="isApplicationDocument" :is-application-document="isApplicationDocument"
:progress-steps="progressSteps" :progress-steps="progressSteps"
:current-progress-ring-motion="currentProgressRingMotion" :current-progress-ring-motion="currentProgressRingMotion"
:claim-id="request.claimId"
:refresh-key="expenseCaseTimelineRefreshKey"
/> />
<div class="detail-grid"> <div class="detail-grid">
<section class="detail-left"> <section class="detail-left">

View File

@@ -347,6 +347,12 @@ export function useTravelRequestDetailSetup(props, { emit }) {
isCurrentDirectManagerApprover: isCurrentDirectManagerApprover.value 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 = { const currentProgressRingMotion = {
initial: { scale: 1, opacity: 0.34 }, initial: { scale: 1, opacity: 0.34 },
enter: { enter: {
@@ -638,6 +644,7 @@ export function useTravelRequestDetailSetup(props, { emit }) {
canApproveRequest, canApproveRequest,
canReturnRequest, canReturnRequest,
currentProgressRingMotion, currentProgressRingMotion,
expenseCaseTimelineRefreshKey,
expenseItems, expenseItems,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS, expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
hasLeaderApprovalEvents, hasLeaderApprovalEvents,

View File

@@ -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, /<TravelRequestExpenseCaseTimeline/)
assert.match(progressCard, /TravelRequestExpenseCaseTimeline/)
assert.match(timelineComponent, /费用事件时间线/)
assert.match(timelineComponent, /当前单据尚未纳入统一费用事件/)
assert.match(expenseCaseService, /\/expense-cases\/by-claim\//)
})

View File

@@ -7,8 +7,8 @@ const detailStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)), fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
'utf8' 'utf8'
) )
const detailTemplate = readFileSync( const progressTemplate = readFileSync(
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)), fileURLToPath(new URL('../src/components/travel/TravelRequestProgressCard.vue', import.meta.url)),
'utf8' 'utf8'
) )
const responsiveStyles = readFileSync( const responsiveStyles = readFileSync(
@@ -31,8 +31,8 @@ test('detail hero facts keep document number and date on one row on laptop scree
}) })
test('reimbursement progress title and long linked application status are compact', () => { test('reimbursement progress title and long linked application status are compact', () => {
assert.match(detailTemplate, /isApplicationDocument \? '申请进度' : '报销进度'/) assert.match(progressTemplate, /isApplicationDocument \? '申请进度' : '报销进度'/)
assert.doesNotMatch(detailTemplate, /差旅进度/) 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]*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;/) assert.match(detailStyles, /\.progress-step-status \{[\s\S]*white-space:\s*nowrap;[\s\S]*overflow:\s*hidden;[\s\S]*text-overflow:\s*ellipsis;/)
}) })