From 22669a90717246009c1b28c59b06b3bccca67138 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Mon, 13 Jul 2026 16:58:51 +0800 Subject: [PATCH] feat(expenses): show unified expense event timeline --- .../CONCEPT.md | 4 + .../TODO.md | 10 +- .../travel-request-expense-case-timeline.css | 253 +++++++++++++++++ .../TravelRequestExpenseCaseTimeline.vue | 104 +++++++ .../travel/TravelRequestProgressCard.vue | 11 +- .../requests/useExpenseCaseTimeline.js | 91 +++++++ web/src/services/expenseCases.js | 10 + web/src/utils/expenseCaseTimeline.js | 194 +++++++++++++ web/src/views/TravelRequestDetailView.vue | 2 + .../views/scripts/travelRequestDetailSetup.js | 7 + web/tests/expense-case-timeline.test.mjs | 257 ++++++++++++++++++ .../travel-request-detail-responsive.test.mjs | 8 +- 12 files changed, 944 insertions(+), 7 deletions(-) create mode 100644 web/src/assets/styles/components/travel-request-expense-case-timeline.css create mode 100644 web/src/components/travel/TravelRequestExpenseCaseTimeline.vue create mode 100644 web/src/composables/requests/useExpenseCaseTimeline.js create mode 100644 web/src/services/expenseCases.js create mode 100644 web/src/utils/expenseCaseTimeline.js create mode 100644 web/tests/expense-case-timeline.test.mjs 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 @@ + + + + + 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;/) })