feat(detail-page-structure-progress-actions): 统一详情处理进度与操作区

这个提交包含在:
2026-08-03 16:00:42 +08:00
父节点 8d428bd51d
当前提交 b7d5761067
共修改 31 个文件,包含 1453 行新增和 383 行删除
+71
查看文件
@@ -489,3 +489,74 @@ const label = status === "draft" ? "草稿" : status;
{["reviewed", "failed"].includes(batch.status) ? <PaymentRegistrationFields /> : null} {["reviewed", "failed"].includes(batch.status) ? <PaymentRegistrationFields /> : null}
{needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null} {needsBankReceipt ? <UploadReceipt /> : canRegister ? <RegisterPaid /> : null}
``` ```
## Scenario: Shared Payment Processing Projection
### 1. Scope / Trigger
- Trigger: adding or changing payment-stage labels, status projection, receipt/order guidance, reimbursement progress or payment-batch detail progress.
- This is a read-only cross-layer projection. It does not add workflow tasks, alter the payment write state machine or relax receipt/maker-checker gates.
### 2. Signatures
- Shared service: `server/src/services/paymentProcessingProgress.js`.
- Builder: `buildPaymentProcessingSemantics({ status, createdAt, reviewedAt, executedAt, cancelledAt, cancelledReason, requiresBankReceipt, receiptCount, paymentOrderExportCount })`.
- Channel helpers: `resolveItemPaymentChannel(batch, item)` and `requiresBankReceipt(batch)`.
- Payment detail: `GET /api/payment-batches/:id` returns `processingProgress.stages[]`; list responses do not return this field.
- Stage states: `completed | current | pending | exception | terminated`.
### 3. Contracts
- The shared service is the only runtime source for payment stage keys, labels and `draft|reviewed|executed|failed|cancelled` state projection. Reimbursement and payment-batch routes only assemble their own DTO view, resolve actor names and add permission-gated links.
- Reimbursement keeps its single-item `paymentBatchItem` view; payment detail keeps the whole-batch view. Do not replace these with one database-aware DTO builder, and do not add route-local status branches after calling the shared builder.
- `requiresBankReceipt` is shared with the execute guard and resolves the effective item channel, including the legacy null-channel reimbursement fallback to `bank`. Receipt count alone must never decide whether a receipt is required.
- A reviewed non-bank batch is current at `付款完成` with guidance to register completion. It must not mention uploading a bank receipt or generating a bank order.
- A reviewed bank batch with no active receipt distinguishes no order vs order generated; an active receipt changes guidance to registration. Payment-order count affects copy only when `requiresBankReceipt=true`.
- `failed` uses `exception`. A cancellation before review makes the review stage `exception` and payment stage `terminated`; a cancellation after review completes review and makes payment `exception`. `terminated` is a neutral stopped state, not pending, completed or failed.
- `actorSource=creator|reviewer|executor|canceller` is an internal semantic reference. The shared service never queries employees; each response assembler resolves only a nullable actor name and never exposes actor IDs inside progress.
### 4. Validation & Error Matrix
- Unknown/missing status -> created remains completed and downstream stages remain pending; do not guess a current or completed business fact.
- `reviewed + requiresBankReceipt=false` -> current payment guidance is registration only, regardless of receipt/order counts.
- `reviewed + requiresBankReceipt=true + receiptCount=0 + exportCount=0` -> guide offline payment and receipt upload.
- Same with `exportCount>0` -> say the bank order was generated and wait for payment/receipt; do not imply money moved.
- `reviewed + requiresBankReceipt=true + receiptCount>0` -> guide registration completion.
- `cancelled + reviewedAt=null` -> review `exception`, payment `terminated`; `cancelled + reviewedAt!=null` -> review `completed`, payment `exception`.
- Missing actor row or timestamp -> corresponding name/time is null; the detail response still succeeds.
### 5. Good/Base/Bad Cases
- Good: both pages import the same semantic builder, while the reimbursement route selects two payment stages and the payment route returns all three.
- Base: a cash batch without a receipt shows `等待财务登记付款完成` and exposes the permitted registration action.
- Bad: a route adds `if (batch.status === "reviewed")` to rewrite stage state/detail, or a client infers receipt requirements from `receiptCount===0`.
- Bad: a cancelled batch shows a later `待处理` node, or a cash batch tells finance to upload a bank receipt.
### 6. Tests Required
- Pure table-driven unit tests cover all five statuses, both cancellation positions, bank/non-bank channels, order count, receipt count and actorSource.
- A structural test extracts both route assembler functions and rejects route-local payment status matrices or duplicate `付款复核` / `付款完成` label definitions.
- Route tests assert actor name/time projection, list/detail field separation and absence of snapshot, bank ciphertext and actor IDs inside progress.
- PC/mobile E2E uses synthetic responses for all five states, verifies `terminated`, bank/non-bank copy, one visible primary action per viewport and upload-to-register in-place switching.
- Reimbursement E2E asserts the four-block order and that submit, print, remind, withdraw and delete retain their state/permission, reason/confirmation, request and refresh contracts after action-panel migration.
### 7. Wrong vs Correct
#### Wrong
```js
if (batch.status === "reviewed" && receiptCount === 0) {
paymentStage.detail = "请上传银行回单";
}
```
#### Correct
```js
const semantics = buildPaymentProcessingSemantics({
status: batch.status,
requiresBankReceipt: requiresBankReceipt(batch),
receiptCount: batch._count?.receipt_attachments ?? 0,
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
});
```
@@ -0,0 +1,3 @@
{"file":".trellis/spec/hyb-oa/approval-workflow.md","reason":"只读复核五状态、取消终止态和 maker-checker 语义未漂移"}
{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"复核新增付款进度 DTO 不扩大银行敏感信息披露"}
{"file":".trellis/spec/hyb-oa/project-governance.md","reason":"状态机相关变更的只读 reviewer 与交付门禁"}
@@ -0,0 +1,109 @@
# 详情页结构统一与付款处理时间线设计
## 1. 设计边界
本任务不合并报销 workflow 与付款批次状态机,只建立共享的只读付款阶段投影,并在两个业务视角中组装各自 DTO。数据库、付款写接口、权限和审计均保持不变。
前端复用现有详情工作区、时间线、固定底栏、确认弹窗和按钮组件。项目声明的 `frontend-design` skill 当前不可用,本轮不发明新的视觉体系;规划阶段已使用可用的 `ui-ux-pro-max` 检查 sticky 遮挡、异步反馈、派生状态和移动端安全区原则,最终以仓库既有成熟详情模式为实现依据。
## 2. 服务端共享语义层
新增一个无数据库依赖的付款进度服务模块,例如 `server/src/services/paymentProcessingProgress.js`。模块导出:
- 唯一的阶段定义:`payment_batch_created / 批次创建`、`payment_batch_review / 付款复核`、`payment_and_receipt / 付款完成`。
- 唯一状态集合:`completed | current | pending | exception | terminated`。
- `buildPaymentProcessingSemantics(input)`:输入批次状态、复核/执行/取消时间、取消原因、`requiresBankReceipt`、回单数和制单次数,返回三个阶段的 state、result、detail、comment、actorSource 与 occurredAt。
`actorSource` 只使用 `creator/reviewer/executor/canceller` 这类内部语义引用,不在共享层查询姓名,也不产生页面链接。状态矩阵:
| 批次事实 | 批次创建 | 付款复核 | 付款完成 |
| --- | --- | --- | --- |
| draft | completed | current | pending |
| reviewed,需要回单、无回单、未制单 | completed | completed | current:等待线下付款并上传回单 |
| reviewed,需要回单、无回单、已制单 | completed | completed | current:已生成制单,等待付款并上传回单 |
| reviewed,需要回单、已有回单 | completed | completed | current:等待登记付款完成 |
| reviewed,不需要回单 | completed | completed | current:等待登记付款完成 |
| executed | completed | completed | completed |
| failed | completed | completed | exception |
| cancelled,`reviewedAt=null` | completed | exception(取消) | terminated |
| cancelled,`reviewedAt!=null` | completed | completed | exception(取消) |
取消节点使用取消人、取消时间与原因;失败节点使用最后执行人和执行时间。语义函数不猜测缺失人员,时间戳缺失时保持 null。
`requiresBankReceipt` 必须复用付款执行门禁的渠道判断:只要仍待处理/失败的批次项中存在有效付款渠道为 `bank` 的项目即为 true。路由不得为时间线再发明一套渠道判断;最小实现是提取并共享现有 `requiresBankReceipt` 计算函数。只有该值为 true 时,制单次数才参与阶段文案。
## 3. 两个视图组装层
### 3.1 报销详情
`buildProcessingProgress` 继续组装提交与真实 workflow task。付款部分调用共享语义函数并只选择 `payment_batch_review`、`payment_and_receipt`:
- 通过已有 `paymentActorNames` 把 actorSource 对应 ID 解析为姓名。
- 按现有权限决定 `targetHref`。
- 无付款批次时保留“待财务安排付款”的报销专属投影。
- 历史 `paid` 且无批次时继续用报销 `paid_by/paid_at` 完成最后阶段。
报销组装层不得重新按 batch status 写 switch/if;批次存在时的 state、result 和基础 detail 一律来自共享语义。
### 3.2 付款批次详情
付款批次详情 DTO 新增 `processingProgress.stages[]`,使用共享语义的三个阶段:
- `mapBatch` 列表响应保持轻量,不额外查姓名。
- `mapBatchForResponse` 仅为详情及写操作返回结果收集 created/reviewed/executed/cancelled actor ID,以一次 `employee.findMany` 解析姓名,然后组装 progress。
- API 类型拆成列表基础类型与带必需 `processingProgress` 的详情类型,避免把详情字段伪装成所有列表项都存在。
- progress 只含姓名和时间,不增加员工号、角色、账号、快照或相邻批次信息。
服务端结构测试直接导入共享阶段定义/构建器,并验证两个路由没有各自状态矩阵,防止未来再次分叉。
## 4. 共享时间线组件
将 `ReimbursementProcessingProgress` 改名并移动到共享 patterns/common 目录,例如 `ProcessingProgress.tsx`。保持单个 `stages.map`:
- 组件自带通用 `ProcessingStage` / `ProcessingProgress` 类型,报销与付款 API 类型复用或结构兼容。
- props 参数化 `ariaLabel`、`testId`、`mobile` 与目标链接文案;报销仍显示“查看所在付款批次”,付款批次本身不生成自指链接。
- 新增 terminated 的中性图标、灰色连接/节点与“已终止”,不使用红色异常或绿色完成视觉。
- current/exception/comment 继续展开 detail;pending/terminated 不显示会误导为下一步的说明。
## 5. 页面结构
### 5.1 报销 PC
- hero 仅保留标题和状态;现有 primary/secondary business actions 迁入右侧 `BusinessDetailActionPanel`。
- `BusinessDetailMain` 顺序为概况、处理进度、明细、材料。
- `BusinessDetailAside` 承载一个操作面板;提交、打印、催办、撤回、删除等继续使用现有权限、mutation 和确认逻辑。
- 删除 `ReimbursementActions` 的独立卡片实现及三个进度锚点;不复制动作。
### 5.2 报销移动端
只移动现有处理进度 section 到概况后;明细、材料随后,底部操作 section 与现有固定提交栏保持末尾/safe-area 合同。
### 5.3 付款批次响应式详情
- `BusinessDetailMain` 为概况、处理进度、明细、回单;旧 `payment-batch-current-processing` 事实卡删除。
- `BusinessDetailAside` 在 `lg+` 显示 `BusinessDetailActionPanel`。
- 同一 `paymentActionModel` 从权限、批次状态、制单次数、回单查询和 mutation 状态派生唯一 primary、secondary、more action;PC 面板和移动端只负责渲染。
- `<lg` 使用现有 `mobile-sticky-action-surface`:一个 primary,其他动作进入“更多”。有固定栏时给页面增加安全底部 padding;无可执行动作时不渲染空栏。
- 上传回单后通过既有 query 刷新让 action model 从“上传银行回单”切到“登记付款完成”。持续可见操作区就是就近入口;回单 section 不再额外复制一个同名按钮。
## 6. 共享断点
把 `detail-workspace-grid` 的双栏断点与 `BusinessDetailActionPanel` sticky 同步从 `xl` 降到 `lg`;同步清理 `WorkflowTaskDetailPage` 的 `xl:top-*` 覆盖,避免同一组件存在冲突断点。
回归 `AssetRegistryPage` 与 `WorkflowTaskDetailPage`:右栏最小宽度、焦点菜单 z-index、sticky top offset 和页面滚动均不得回退。sticky 元素不设置超过视口的固定高度,不能遮挡主内容或底部操作。
## 7. 测试与验收数据
- 纯函数表驱动测试锁定五状态、取消时间戳分支、需要/不需要回单、制单/回单文案和 actorSource。
- 报销路由保留既有动态 workflow task 测试并将五种 batch 状态期望改为共享语义。
- 付款批次详情路由测试断言三阶段、actor name/time、无敏感字段;列表不额外依赖 progress。
- Playwright 通过 synthetic API fixtures 覆盖五状态,不写生产数据;断言每个 viewport 只有一个可见主操作而非全 DOM 只有一个按钮。
- 报销动作 E2E 逐项覆盖:draft 提交与删除、reviewing 催办与撤回、paid 打印,并核对权限隐藏、原因/确认交互、mutation 请求及刷新后的状态;操作区迁移不能降低既有写操作证据强度。
- 生产验收只观察真实存在的 draft/reviewed;后三态无真实数据是已知限制,不阻塞交付。
## 8. 兼容、发布与回滚
- API 是加字段的向后兼容变化,无迁移;旧前端忽略 progress,新前端部署必须与新增 DTO 同批发布。
- 回滚只需恢复旧路由投影与页面布局,付款写状态和数据不受影响。
- 面向用户测试仍需单独批准部署,跑标准聚合体检、公网构建标识/ready 检查后才通知。
- 下一笔制单是否进入网银由“存在明确仅校验步骤”决定;无该步骤时只执行离线验证和人工付款。
@@ -0,0 +1,3 @@
{"file":".trellis/spec/hyb-oa/backend-fastify-prisma.md","reason":"服务端只读 DTO、路由和聚焦验证合同"}
{"file":".trellis/spec/hyb-oa/approval-workflow.md","reason":"报销端到端处理投影、付款复核与状态机边界"}
{"file":".trellis/spec/hyb-oa/security-privacy.md","reason":"付款详情 DTO 与银行资料最小披露边界"}
@@ -0,0 +1,74 @@
# 详情页结构统一与付款处理时间线实施计划
## 1. 开发前
- [x] 用户审查并明确批准本 PRD、设计和实施清单后,运行 `task.py start`;批准前不修改产品代码。
- [x] 使用 `trellis-before-dev` 读取 backend、frontend、hyb-oa 相关规约与当前任务合同。
- [x] 记录基线:聚焦报销/付款路由测试、前端 typecheck/build 和相关 Playwright 当前结果。
## 2. 服务端语义与 DTO
- [x] 先提取与执行门禁同源的 `requiresBankReceipt` 渠道判断,再新增共享付款阶段语义模块及表驱动单元测试,覆盖五状态、取消前后复核、需要/不需要回单、制单/回单分支和 actorSource。
- [x] 报销 `buildProcessingProgress` 改为消费共享语义,保留无批次、权限链接和历史 paid 兼容;更新既有详情路由测试。
- [x] 付款批次详情组装 actor names 与三阶段 `processingProgress`;列表映射保持轻量,新增隐私负向断言。
- [x] 更新客户端付款详情 API 类型,明确列表/详情字段边界。
## 3. 共享前端组件
- [x] 将报销专用进度组件收敛为共享组件,参数化无障碍名称、testid、目标链接文案并增加 terminated 状态。
- [x] 更新 PC/移动报销调用方与相关 fixture,保持动态阶段数量和链接权限行为。
- [x] 将详情工作区双栏、操作面板 sticky 及页面覆盖断点统一为 `lg`,先回归资产与审批任务详情。
## 4. 报销详情
- [x] PC 迁移到标准 Main/Aside 操作区,主内容按概况、处理进度、明细、材料排列;移除 hero 动作重复、旧手写动作卡和三个进度锚点。
- [x] 移动端把处理进度移到概况之后,保持操作 section 在底部和现有 safe-area 固定提交动作。
- [x] 补 PC/移动四段顺序、锚点删除和无重复主操作回归;逐项覆盖提交、打印、催办、撤回、删除的权限/状态可见性、原因/确认、请求与刷新结果。
## 5. 付款批次详情
- [x] 建立单一派生 `paymentActionModel`,沿用现有权限、原因、确认、loading 和 mutation handler。
- [x] 主内容改为概况、共享三阶段时间线、明细、回单,删除“当前处理”事实卡及页面内状态文案矩阵。
- [x] `lg+` 使用标准粘性操作面板;移动端使用 safe-area 固定底栏,一个 primary 加“更多”。
- [x] 验证上传回单成功后原地切换为“登记付款完成”,没有第二个同名主按钮。
- [x] 更新既有付款 E2E locator,使其按当前 viewport/操作区作用域断言;合成覆盖全部五状态和需要/不需要回单渠道,非银行批次不得出现回单/制单引导。
## 6. 文档与稳定规约
- [x] 确认管理员手册已区分“制单离线验证”和“使用制单付款”,无安全仅校验环节时仍人工付款。
- [x] 将共享付款进度的稳定状态/终止态合同更新到 `.trellis/spec/hyb-oa/approval-workflow.md`;不把页面实现细节复制到业务文档。
- [x] 如未发现新的稳定通用规则,不新建额外文档。
## 7. 验证与只读审查
- [x] `cd server && node --check src/services/paymentProcessingProgress.js && node --check src/routes/reimbursements.js && node --check src/routes/paymentBatches.js`
- [x] 运行新增纯函数测试、`reimbursements.detail.test.js` 和付款批次聚焦路由测试。
- [x] `cd server && npm run prisma:validate`
- [x] `cd client && npm run typecheck && npm run build`
- [x] 运行相关 Playwright:报销 PC/移动详情、付款批次五状态/动作确认、资产与审批任务详情 `lg` 回归。
- [x] 因涉及付款状态投影,启动只读 reviewer 审查状态语义、权限/隐私、双视角 DTO 和 PC/移动动作一致性;reviewer 不修改代码。
- [x] 修正 findings 后重跑受影响检查,再执行 `bash scripts/check-minimal.sh`(若耗时门禁适用)。
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`
- [x] `node scripts/qa/audit-docs.mjs --check-registry`
- [x] `node scripts/qa/check-trellis-governance.mjs`
- [x] `git diff --check`
## 8. 提交、部署与验收
- [x] 质量检查通过后按 Trellis finish 流程更新必要 spec/docs,使用中文 commit message 并引用 `detail-page-structure-progress-actions`。
- [x] 未经单独批准不部署;批准后走标准部署聚合体检、公网 ready/build 检查。
- [x] 公网通过后只通知用户验收真实可见状态;记录生产缺少 executed/failed/cancelled 的限制,后续真实 executed 仅作补充观察。
## 验证记录(2026-08-03)
- `bash scripts/check-minimal.sh` 在固定 BIFF8 Python 依赖环境下通过:服务端 177 个测试文件、1213 个用例全部通过,Prisma、前端 typecheck/build 与其余聚合步骤通过。
- 聚焦服务端共享语义、报销详情、付款详情与制单回归通过;普通付款进度 DTO 无账号、密文、原始快照或 actor ID。
- 聚焦 Playwright 覆盖报销动作、付款五状态、银行/非银行渠道、PC/移动唯一主操作、上传后原地切换和 `lg` sticky 合同。审查 finding 修复后,制单可用且未导出时仍以“上传银行回单”为主操作,首次制单进入“更多”。
- 只读 reviewer 首轮发现“制单被误设为强制主路径”的 High finding;修复及 PC/移动回归后复核 `PASS`,reviewer 未修改代码。
- 文档审计 `errors=0`,保留仓库既有的 22 个未注册文档 warning;Trellis、流程、敏感内容与 `git diff --check` 门禁通过。
## 回滚点
- 服务端共享投影与前端 DTO/UI 同一提交批次回滚,避免新前端依赖旧 DTO。
- 若共享 `lg` 断点导致现有资产/审批详情无法接受,先修正共享列宽和 top offset;不得用页面私有断点重新制造第二事实源。
- 本任务无迁移、无生产数据回填、无付款状态写入,代码回滚不需要数据库操作。
@@ -0,0 +1,111 @@
# 详情页结构统一与付款处理时间线
## Goal
让员工、审批人和财务在报销详情与付款批次详情中按同一认知顺序查看“当前到哪、谁处理、下一步做什么”,并让高频主操作在 PC 和移动端持续可达,不再因页面不同而重新理解同一付款事实或来回滚动。
## Background
- 报销详情 PC 当前顺序为“概况 → 明细 → 材料 → 处理进度”,移动端同样要滚过不定长材料后才能看到进度;PC 还有三个用于跳转到底部进度的入口,其中 `#reimbursement-workflow-progress-panel` 没有目标元素,另两个入口在进度前移后也不再有价值。
- 报销详情把付款批次状态投影为时间线,付款批次详情却在“当前处理”事实卡中独立维护另一套状态、责任与下一步文案。同一付款事实已有两种表达和两条判定路径。
- 报销进度从单笔 `paymentBatchItem` 视角说明“这一笔在批次中的位置”;付款批次详情从整个 `paymentBatch` 视角说明“整批走到哪”。两者不能共用一个大 DTO 构建函数,但付款阶段标签和状态判定必须同源。
- PC 报销详情使用顶部 hero 动作加底部手写“可执行动作”卡;付款批次详情把动作放在顶部摘要卡。项目已有 `BusinessDetailActionPanel`,但其工作区和 sticky 均只在 `xl` 生效。移动端已有 safe-area 固定底部操作模式。
- 当前生产没有真实 `executed` 付款批次;这不应阻塞任务完成,五种状态由合成数据自动化覆盖,生产只验收届时真实存在的状态。
## Scope
- 调整报销详情 PC/移动端与付款批次响应式详情的信息顺序和操作区,但不扩展到其他业务详情页重构。
- 新增共享的只读付款阶段语义和付款批次三阶段 DTO,报销与付款分别保留自己的视图组装层。
- 收敛通用时间线组件、详情工作区 `lg` 断点及 PC/移动动作派生,回归既有共享组件消费者。
- 更新合成自动化、稳定工程规约和下一笔制单的安全操作说明;不执行部署、生产配置、网银导入或真实付款。
## Requirements
### R1 详情信息顺序
- 报销详情 PC 和移动端均调整为“概况 → 处理进度 → 报销明细 → 报销材料”;移动端底部“操作”段仍留在业务内容之后。
- 删除 PC 的三个进度锚点动作,包括一个死链和两个指向现有处理进度面板的自指入口;返回、打印、催办、提交、撤回等真实动作保持可达。
- 付款批次详情按“批次概况 → 处理进度 → 报销明细 → 银行回单”组织主内容。
### R2 付款处理语义单一事实源
- 建立共享付款阶段语义层,唯一维护阶段 key、标签及 `completed/current/pending/exception/terminated` 判定;“付款复核”“付款完成”及其状态条件不得在报销路由和付款批次路由各写一套。
- 共享语义层按批次状态和时间戳判断阶段;两个调用方仅负责各自视角的 DTO 组装、责任人姓名解析、权限链接和报销历史兼容信息。
- 报销视角继续保留提交、真实审批任务和两个付款阶段;付款批次视角展示“批次创建 → 付款复核 → 付款完成”三阶段。
- `draft`、`reviewed`、`executed`、`failed`、`cancelled` 五种付款批次状态必须有明确投影。`failed` 的付款阶段为 `exception`;`cancelled` 根据是否已有复核时间终止在复核或付款阶段,后续阶段为 `terminated`,不得显示仍待处理。
- 共享语义层必须接收与执行门禁同源的 `requiresBankReceipt`,不能仅凭回单数判断下一步。`reviewed` 且不需要回单时直接显示等待登记付款完成;需要回单且尚未上传时才按制单次数区分:未导出显示“等待财务线下付款并上传回单”,已导出显示“已生成银行制单,等待付款完成并上传回单”;需要回单且已有回单时显示等待登记付款完成。
- 制单中间态只适用于需要银行回单的渠道;现金等不需要回单的批次不得出现上传银行回单或已生成银行制单的下一步文案。
- 付款批次处理进度显示已有的真实创建人、复核人、执行人或取消人及时间;不得猜测未来责任人。
### R3 共享时间线表现
- 将现有 `ReimbursementProcessingProgress` 收敛为共享进度组件;保留当前数据驱动渲染,不重做成带大量配置项的新组件。
- 共享组件使用通用阶段类型,并参数化 `aria-label`、测试标识和目标链接文案;PC 与移动端继续使用相同渲染逻辑。
- `terminated` 使用中性终止视觉和“已终止”状态,不表现为错误、完成或待处理;`exception` 继续用于失败和发生取消动作的终止节点。
### R4 响应式操作区
- PC 报销与付款批次详情收敛到 `BusinessDetailWorkspace/Main/Aside + BusinessDetailActionPanel`;hero/概况卡不再保留第二套业务主操作。
- 详情工作区双栏和操作面板 sticky 从 `xl` 调整到 `lg`,并回归现有 `AssetRegistryPage` 与 `WorkflowTaskDetailPage` 消费方,确保笔记本宽度可见且不遮挡内容。
- 付款批次的 PC 粘性面板与移动端固定底栏必须消费同一份动作可见性/主次级模型,不能各自重复判断状态和权限。
- 移动端只在固定底栏展示一个当前主操作;取消、重新下载等次要或破坏性动作进入“更多”并保留既有原因输入、风险提示和二次确认。
- 上传银行回单成功后,当前主操作在原可视区域切换为“登记付款完成”,用户无需滚回页面顶部。不得通过同时渲染两个同名主按钮来实现就近入口。
- 所有异步动作在执行中保持禁用和加载反馈;高风险付款、复核、取消及重新下载制单的既有确认合同不放宽。
### R5 兼容与边界
- 本任务只增加只读处理进度投影和调整页面布局/动作呈现,不改变付款批次写状态机、权限、maker-checker、回单门禁、快照、加密或审计合同。
- 不新增数据库字段或迁移;服务端普通响应不得新增完整账号、密文、原始快照或批次相邻敏感数据。
- 历史已付款但无付款批次的报销继续可读;无批次、无工作流、无回单或责任人记录缺失时,只降级对应阶段,不使整个详情失败。
### R6 下一笔制单安全说明
- 等下一张报销自然形成新快照批次,不取消重建杨俊当前已复核的旧批次。
- 下一笔新批次的“验证制单”和“使用制单付款”是两步:若建设银行网银没有明确的“仅校验、不提交”环节,只做导出、Excel 打开和固定版本 `xlrd` 读回的离线验证,实际付款仍走人工;不得因文件生成成功直接导入提交。
## Test Plan
- 服务端纯语义单元测试用合成数据覆盖 `draft/reviewed/executed/failed/cancelled`,以及取消前后是否已有复核时间、需要/不需要回单、未制单/已制单/已有回单分支。
- 扩展报销详情路由测试,证明其付款阶段与共享语义一致、无批次和历史已付款兼容不回退;扩展付款批次详情测试,证明三阶段 DTO、真实 actor name/time 和普通 DTO 隐私边界。
- 前端类型检查与构建;共享时间线覆盖五种状态与 `terminated` 表现。
- Playwright 使用合成响应覆盖付款批次五种状态、需要/不需要回单、PC/移动端动作唯一性、上传回单后主操作切换、报销 PC/移动端四段顺序、三个旧锚点消失,以及 `lg` sticky 相关消费者回归。
- 报销动作迁移必须分别保留提交、打印、催办、撤回、删除的权限/状态可见性、确认或原因交互、成功 mutation 与刷新结果断言;不能只检查操作面板里出现了按钮。
- 实施后按项目约束启动只读 reviewer,重点审查状态机投影、权限/隐私、PC/移动动作一致性,不允许 reviewer 修改代码。
- 运行 Trellis、流程、文档、敏感内容和 `git diff --check` 门禁;用户可测试前仍须另行批准部署并通过公网检查。
## Acceptance Criteria
- [x] 报销 PC 与移动端均按“概况 → 处理进度 → 明细 → 材料”展示,PC 三个旧进度锚点全部消失,移动端操作仍在底部。
- [x] 付款批次详情用三阶段时间线替换“当前处理”事实卡,标题统一为“处理进度”,并显示真实已有责任人和时间。
- [x] 两个服务端调用方共同使用唯一付款阶段语义层;阶段标签和五状态判定不存在第二份实现,结构测试能阻止重新分叉。
- [x] `draft/reviewed/executed/failed/cancelled` 均由合成测试覆盖;失败显示 exception,取消在正确节点结束且后续显示 terminated,不再挂待处理。
- [x] reviewed 需要回单时的未制单、已制单、已有回单三种文案与既定口径一致;不需要回单时直接等待登记完成且不出现银行回单/制单引导。
- [x] 共享时间线同时服务报销 PC、报销移动端和付款批次,保留无障碍名称、稳定 testid、目标链接及中止态视觉。
- [x] PC 报销与付款批次详情使用标准右侧操作面板,`lg` 笔记本宽度可粘性跟随;资产与审批任务详情无布局回退。
- [x] 付款批次移动端使用 safe-area 固定底栏,PC/移动动作来自同一模型;每个视口只有一个当前主操作。
- [x] 上传回单成功后无需滚动即可点击“登记付款完成”,且继续使用现有二次确认和回单硬门禁。
- [x] 报销提交、打印、催办、撤回、删除迁移后各自的权限/状态可见性、确认或原因交互、成功刷新合同均有 E2E 回归,尤其锁定仅 reviewing 可撤回、仅 draft 可删除。
- [x] 无数据库迁移、权限扩大或付款写状态机变化,普通响应不泄露银行敏感字段。
- [x] 管理员手册明确下一笔制单仅在网银有“仅校验、不提交”环节时才进入网银,否则只做离线验证并人工付款。
- [x] 自动化验收覆盖全部五状态;生产验收只覆盖当时已有真实状态(预计 draft/reviewed),缺少 executed/failed/cancelled 真实数据不阻塞任务完成,后续真实 executed 仅作补充验收。
## Out of Scope
- 取消或重建杨俊当前付款批次、执行真实付款、上传真实回单或写入任何生产银行数据。
- 等待下一笔真实付款完成后再结束本任务,或把生产 `executed` 数据设为本任务验收门槛。
- 企业网银导入、提交、放款、银行 API 直连或跨行制单能力。
- 修改付款状态转换、maker-checker、回单要求、银行账户/快照模型、制单格式或导出权限。
- 统一所有业务详情页、列表页、编辑页或重新设计视觉体系。
- 新建数据库迁移、补写历史付款快照或为测试制造生产业务数据。
## Risks And Deferred Items
- `detail-workspace-grid` 与 `BusinessDetailActionPanel` 的断点是共享合同,调整时必须回归资产和审批任务详情;若 `lg` 宽度不足,应通过列宽/间距收敛解决,不退回页面私有 sticky。
- 当前生产缺少后三种真实状态,自动化是本任务的完成证据;真实 `executed` 批次出现后可追加一次生产观察,但不重新打开本任务。
- 建设银行网银是否存在安全的仅校验步骤仍未知;这只决定后续是否做网银格式确认,不改变本任务产品行为。
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
@@ -0,0 +1,26 @@
{
"id": "detail-page-structure-progress-actions",
"name": "detail-page-structure-progress-actions",
"title": "详情页结构统一与付款处理时间线",
"description": "统一报销与付款批次详情页的信息顺序、处理进度表达和响应式操作区,并以共享付款阶段语义消除跨页面状态漂移。",
"status": "in_progress",
"dev_type": "fullstack",
"scope": "报销详情 PC/移动端结构、付款批次三阶段进度 DTO、共享时间线组件、PC/移动端操作区与相关测试文档",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-08-03",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "已完成共享付款进度语义、报销与付款详情结构、PC/移动操作区收敛及合成五状态回归;只读审查 finding 已修复并复核通过。未部署,未执行生产写入、网银导入或真实付款。",
"meta": {}
}
+6 -1
查看文件
@@ -1,6 +1,7 @@
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http"; import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { RiskFlag } from "./feeControl"; import type { RiskFlag } from "./feeControl";
import type { ProcessingProgress } from "../types/processingProgress";
export type PaymentBatchEntityType = "reimbursement" | "travel_order"; export type PaymentBatchEntityType = "reimbursement" | "travel_order";
export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled"; export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
@@ -75,6 +76,10 @@ export type PaymentBatch = {
items: PaymentBatchItem[]; items: PaymentBatchItem[];
}; };
export type PaymentBatchDetail = PaymentBatch & {
processingProgress: ProcessingProgress;
};
export type PaymentBatchCandidate = { export type PaymentBatchCandidate = {
entityId: string; entityId: string;
entityType: "reimbursement"; entityType: "reimbursement";
@@ -123,7 +128,7 @@ export async function fetchPaymentBatches(query: { entityType?: PaymentBatchEnti
} }
export async function fetchPaymentBatchDetail(batchId: string) { export async function fetchPaymentBatchDetail(batchId: string) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}`); return apiRequestData<{ item: PaymentBatchDetail }>(`/api/payment-batches/${batchId}`);
} }
export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number } = {}) { export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number } = {}) {
+3 -15
查看文件
@@ -1,6 +1,7 @@
import { apiRequestBlob, apiRequestData } from "./http"; import { apiRequestBlob, apiRequestData } from "./http";
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl"; import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance"; import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { ProcessingProgress, ProcessingStage } from "../types/processingProgress";
export type ReimbursementStatus = export type ReimbursementStatus =
| "draft" | "draft"
@@ -112,21 +113,8 @@ export type ReimbursementWorkflowProgress = {
}>; }>;
}; };
export type ReimbursementProcessingStage = { export type ReimbursementProcessingStage = ProcessingStage;
key: string; export type ReimbursementProcessingProgress = ProcessingProgress;
label: string;
state: "completed" | "current" | "pending" | "exception";
actorName: string | null;
occurredAt: string | null;
result: string | null;
comment: string | null;
detail: string | null;
targetHref: string | null;
};
export type ReimbursementProcessingProgress = {
stages: ReimbursementProcessingStage[];
};
export type ReimbursementApprovalHistoryItem = { export type ReimbursementApprovalHistoryItem = {
id: string; id: string;
@@ -98,7 +98,7 @@ export function BusinessDetailActionPanel({
const hasContentBeforeSummary = Boolean(primaryAction || secondaryActions || moreActions || hasChildren); const hasContentBeforeSummary = Boolean(primaryAction || secondaryActions || moreActions || hasChildren);
return ( return (
<SectionPanel title={title} actions={status} className={cn("detail-action-panel xl:sticky xl:top-24", className)}> <SectionPanel title={title} actions={status} className={cn("detail-action-panel lg:sticky lg:top-24", className)}>
<div className="detail-action-stack" data-testid={testId}> <div className="detail-action-stack" data-testid={testId}>
{primaryAction ? <div className="grid gap-2">{primaryAction}</div> : null} {primaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{secondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null} {secondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
@@ -1,12 +1,15 @@
import { AlertCircle, Check, Circle, Clock, ExternalLink } from "lucide-react"; import { AlertCircle, Ban, Check, Circle, Clock, ExternalLink } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { ReimbursementProcessingProgress as ProcessingProgress } from "../../api/reimbursements"; import type { ProcessingProgress as ProcessingProgressValue } from "../../types/processingProgress";
import { formatDisplayDateTime } from "../../lib/displayFormat"; import { formatDisplayDateTime } from "../../lib/displayFormat";
import { cn } from "../../lib/utils"; import { cn } from "../../lib/utils";
type Props = { type Props = {
progress: ProcessingProgress; progress: ProcessingProgressValue;
ariaLabel: string;
testId: string;
mobile?: boolean; mobile?: boolean;
targetLinkLabel?: string;
}; };
const STATE_TEXT = { const STATE_TEXT = {
@@ -14,15 +17,18 @@ const STATE_TEXT = {
current: "处理中", current: "处理中",
pending: "待处理", pending: "待处理",
exception: "需处理", exception: "需处理",
terminated: "已终止",
} as const; } as const;
export function ReimbursementProcessingProgress({ progress, mobile = false }: Props) { export function ProcessingProgress({
progress,
ariaLabel,
testId,
mobile = false,
targetLinkLabel = "查看详情",
}: Props) {
return ( return (
<ol <ol className="space-y-0" data-testid={testId} aria-label={ariaLabel}>
className="space-y-0"
data-testid={mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"}
aria-label="报销处理进度"
>
{progress.stages.map((stage, index) => { {progress.stages.map((stage, index) => {
const expanded = stage.state === "current" || stage.state === "exception" || Boolean(stage.comment); const expanded = stage.state === "current" || stage.state === "exception" || Boolean(stage.comment);
const Icon = stage.state === "completed" const Icon = stage.state === "completed"
@@ -31,7 +37,9 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
? Clock ? Clock
: stage.state === "exception" : stage.state === "exception"
? AlertCircle ? AlertCircle
: Circle; : stage.state === "terminated"
? Ban
: Circle;
const facts = [stage.actorName, stage.occurredAt ? formatDisplayDateTime(stage.occurredAt) : null, stage.result] const facts = [stage.actorName, stage.occurredAt ? formatDisplayDateTime(stage.occurredAt) : null, stage.result]
.filter(Boolean); .filter(Boolean);
@@ -47,6 +55,7 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
stage.state === "current" && "border-blue-300 bg-blue-50 text-blue-700", stage.state === "current" && "border-blue-300 bg-blue-50 text-blue-700",
stage.state === "exception" && "border-red-300 bg-red-50 text-red-700", stage.state === "exception" && "border-red-300 bg-red-50 text-red-700",
stage.state === "pending" && "border-zinc-300 text-zinc-400", stage.state === "pending" && "border-zinc-300 text-zinc-400",
stage.state === "terminated" && "border-zinc-300 bg-zinc-100 text-zinc-500",
)} )}
aria-hidden="true" aria-hidden="true"
> >
@@ -69,7 +78,7 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
className="mt-2 inline-flex min-h-11 items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2" className="mt-2 inline-flex min-h-11 items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
to={stage.targetHref} to={stage.targetHref}
> >
查看所在付款批次 {targetLinkLabel}
<ExternalLink aria-hidden="true" className="size-4" /> <ExternalLink aria-hidden="true" className="size-4" />
</Link> </Link>
) : null} ) : null}
+2 -2
查看文件
@@ -585,7 +585,7 @@
} }
.detail-workspace-grid { .detail-workspace-grid {
@apply grid gap-5 xl:grid-cols-[minmax(0,1.45fr),minmax(300px,0.72fr)]; @apply grid gap-5 lg:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)];
} }
.detail-command-panel { .detail-command-panel {
@@ -666,7 +666,7 @@
} }
.workflow-action-panel { .workflow-action-panel {
@apply relative z-30 xl:sticky xl:top-20; @apply relative z-30 lg:sticky lg:top-20;
} }
.workflow-action-bar { .workflow-action-bar {
@@ -44,7 +44,7 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { MobileIcon } from "../components/mobile/MobileIcon"; import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader"; import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary"; import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress"; import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { import {
@@ -944,6 +944,34 @@ export function MobileReimbursementDetailPage() {
) : null} ) : null}
</section> </section>
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
<div className="mt-3">
<ProcessingProgress
progress={detail.processingProgress ?? { stages: [] }}
ariaLabel="报销处理进度"
testId="mobile-reimbursement-processing-progress"
targetLinkLabel="查看所在付款批次"
mobile
/>
</div>
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-items"> <section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2> <h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1253,28 +1281,6 @@ export function MobileReimbursementDetailPage() {
)} )}
</section> </section>
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
<div className="mt-3">
<ReimbursementProcessingProgress progress={detail.processingProgress ?? { stages: [] }} mobile />
</div>
</section>
{showSecondaryActions ? ( {showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions"> <section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2> <h2 className="text-base font-semibold text-zinc-900">操作</h2>
+188 -143
查看文件
@@ -24,6 +24,13 @@ import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { import {
BusinessListToolbar, BusinessListToolbar,
BusinessMobileList, BusinessMobileList,
@@ -44,6 +51,12 @@ import {
DialogTitle, DialogTitle,
} from "../components/ui/dialog"; } from "../components/ui/dialog";
import { Textarea } from "../components/ui/textarea"; import { Textarea } from "../components/ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput"; import { DateTextInput } from "../components/common/DateTextInput";
@@ -54,6 +67,14 @@ import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"]; const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
type PaymentActionKind =
| "review"
| "downloadOrder"
| "uploadReceipt"
| "execute"
| "cancel"
| "redownloadOrder";
function entityTypeLabel(value: string) { function entityTypeLabel(value: string) {
if (value === "reimbursement") return "报销"; if (value === "reimbursement") return "报销";
if (value === "travel_order") return "差旅订单"; if (value === "travel_order") return "差旅订单";
@@ -185,7 +206,6 @@ export function PaymentBatchesPage() {
canManageBatch && canManageBatch &&
!!selectedBatch && !!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status); ["reviewed", "failed"].includes(selectedBatch.status);
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
const canReviewSelectedBatch = const canReviewSelectedBatch =
canReviewBatch && canReviewBatch &&
!!selectedBatch && !!selectedBatch &&
@@ -202,6 +222,8 @@ export function PaymentBatchesPage() {
["pending", "failed"].includes(item.status) && ["pending", "failed"].includes(item.status) &&
(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel) === "bank", (item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel) === "bank",
)); ));
const canUploadReceipt =
canEditReceiptAttachments && requiresBankReceipt && receiptAttachmentsQuery.isSuccess;
const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length; const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length;
const needsBankReceipt = const needsBankReceipt =
canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0; canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0;
@@ -213,6 +235,33 @@ export function PaymentBatchesPage() {
!!selectedBatch && !!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status); !["executed", "cancelled"].includes(selectedBatch.status);
const paymentActionModel = useMemo(() => {
const primary = canReviewSelectedBatch
? "review"
: needsBankReceipt
? "uploadReceipt"
: canRegisterPaymentComplete
? "execute"
: null;
const more: PaymentActionKind[] = [];
if (
selectedBatch?.status === "reviewed" &&
selectedBatch.canExportPaymentOrder &&
canExportPaymentOrder
) {
more.push(selectedBatch.paymentOrderExportCount > 0 ? "redownloadOrder" : "downloadOrder");
}
if (canCancelSelectedBatch) more.push("cancel");
return { primary: primary as PaymentActionKind | null, more };
}, [
canCancelSelectedBatch,
canExportPaymentOrder,
canRegisterPaymentComplete,
canReviewSelectedBatch,
needsBankReceipt,
selectedBatch,
]);
const refresh = async () => { const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] }); await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-candidates"] }); await queryClient.invalidateQueries({ queryKey: ["payment-batch-candidates"] });
@@ -421,6 +470,68 @@ export function PaymentBatchesPage() {
}, },
}); });
const paymentActionLabel = (kind: PaymentActionKind) => {
if (kind === "review") return reviewMutation.isPending ? "复核中..." : "复核通过";
if (kind === "downloadOrder") return paymentOrderMutation.isPending ? "生成中..." : "下载银行制单";
if (kind === "uploadReceipt") return uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单";
if (kind === "execute") return executeMutation.isPending ? "登记中..." : "登记付款完成";
if (kind === "redownloadOrder") return "重新下载制单";
return "取消批次";
};
const paymentActionDisabled = (kind: PaymentActionKind) => {
if (kind === "review") return reviewMutation.isPending;
if (["downloadOrder", "redownloadOrder"].includes(kind)) return paymentOrderMutation.isPending;
if (kind === "uploadReceipt") return uploadReceiptMutation.isPending;
if (kind === "execute") return executeMutation.isPending;
return cancelMutation.isPending || !canCancelSelectedBatch;
};
const activatePaymentAction = (kind: PaymentActionKind) => {
if (!selectedBatch) return;
if (kind === "review" || kind === "execute") {
setConfirmPaymentAction({
kind: kind === "review" ? "financeReview" : "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
});
return;
}
if (kind === "uploadReceipt") {
receiptInputRef.current?.click();
return;
}
setActionReason("");
if (kind === "cancel") {
setReasonAction({ kind: "cancel", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo });
return;
}
setReasonAction({
kind: "paymentOrder",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
reexport: kind === "redownloadOrder",
});
};
const renderPaymentActionButton = (
kind: PaymentActionKind,
variant: "default" | "secondary" | "destructive" = "default",
) => (
<Button
key={kind}
type="button"
variant={variant}
disabled={paymentActionDisabled(kind)}
data-testid={`payment-batch-action-${kind}`}
onClick={() => activatePaymentAction(kind)}
>
{paymentActionLabel(kind)}
</Button>
);
return ( return (
<ListWorkspace> <ListWorkspace>
{downloadReasonDialog} {downloadReasonDialog}
@@ -543,7 +654,11 @@ export function PaymentBatchesPage() {
</Button> </Button>
</div> </div>
) : selectedBatch ? ( ) : selectedBatch ? (
<div className="space-y-5"> <div
className={paymentActionModel.primary || paymentActionModel.more.length ? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)] lg:pb-0" : ""}
>
<BusinessDetailWorkspace testId="payment-batch-detail-workspace">
<BusinessDetailMain>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel"> <div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
@@ -555,89 +670,7 @@ export function PaymentBatchesPage() {
) : null} ) : null}
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />
{canReviewSelectedBatch ? (
<Button
type="button"
disabled={reviewMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financeReview",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
复核通过
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount === 0 ? (
<Button
type="button"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: false });
}}
>
{paymentOrderMutation.isPending ? "生成中..." : "下载银行制单"}
</Button>
) : null}
{needsBankReceipt ? (
<Button
type="button"
disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
) : null}
{canRegisterPaymentComplete ? (
<Button
type="button"
disabled={executeMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
登记付款完成
</Button>
) : null}
{canManageBatch ? (
<Button
type="button"
variant="secondary"
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
onClick={() => {
setActionReason("");
setReasonAction({
kind: "cancel",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
});
}}
>
取消
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount > 0 ? (
<Button
type="button"
variant="secondary"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: true });
}}
>
重新下载制单
</Button>
) : null}
</div>
</div> </div>
<div className="detail-fact-grid text-sm xl:grid-cols-4"> <div className="detail-fact-grid text-sm xl:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div> <div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
@@ -661,59 +694,13 @@ export function PaymentBatchesPage() {
) : null} ) : null}
</div> </div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing"> <div className="finance-command-panel space-y-3" data-testid="payment-batch-processing-panel">
<h3 className="text-base font-semibold text-zinc-900">当前处理</h3> <h3 className="text-base font-semibold text-zinc-900">处理进度</h3>
<div className="detail-fact-grid text-sm md:grid-cols-2"> <ProcessingProgress
<div className="detail-fact-card"> progress={selectedBatch.processingProgress}
<div className="detail-fact-card__label">当前责任</div> ariaLabel="付款批次处理进度"
<div className="detail-fact-card__value"> testId="payment-batch-processing-progress"
{selectedBatch.status === "draft" />
? "负责人(非制单人)"
: ["reviewed", "failed"].includes(selectedBatch.status)
? "财务"
: selectedBatch.status === "executed"
? "已完成"
: "已结束"}
</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">下一步</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "复核通过"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "核对银行回单状态"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "制单已生成,完成付款并上传银行回单"
: "线下付款并上传银行回单"
: selectedBatch.status === "reviewed"
? "登记付款完成"
: selectedBatch.status === "failed"
? "核对失败原因并重试"
: "-"}
</div>
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
{selectedBatch.status === "draft"
? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "已生成银行制单,等待付款完成并上传回单。"
: "付款复核已通过。等待财务线下付款并上传回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
? "付款已登记完成。"
: selectedBatch.status === "failed"
? "付款登记失败,请财务核对失败原因后重试。"
: "该付款批次已取消。"}
{selectedBatch.highRisk ? ` 批次金额达到 ${formatMoney(selectedBatch.reviewRequirement.thresholdAmount)},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div>
</div> </div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel"> <div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
@@ -775,9 +762,11 @@ export function PaymentBatchesPage() {
<div> <div>
<h3 className="text-base font-semibold text-zinc-900">银行回单</h3> <h3 className="text-base font-semibold text-zinc-900">银行回单</h3>
<p className="mt-1 text-sm text-zinc-500"> <p className="mt-1 text-sm text-zinc-500">
{selectedBatch.status === "draft" {!requiresBankReceipt
? "付款复核通过后,由财务在线下完成付款并上传银行回单。" ? "当前付款渠道不要求银行回单,核对付款结果后可直接登记完成。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"} : selectedBatch.status === "draft"
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
</p> </p>
</div> </div>
{canUploadReceipt ? ( {canUploadReceipt ? (
@@ -866,14 +855,70 @@ export function PaymentBatchesPage() {
</div> </div>
</div> </div>
))} ))}
{receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? ( {requiresBankReceipt && receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500"> <div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
暂无银行回单;银行转账必须先上传回单,才能登记付款完成。 暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
</div> </div>
) : null} ) : null}
</div> </div>
</div> </div>
</div> </BusinessDetailMain>
<BusinessDetailAside className="hidden lg:block">
<BusinessDetailActionPanel
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
moreActions={paymentActionModel.more.length ? (
<details className="detail-disclosure">
<summary>更多</summary>
<div className="mt-3 grid gap-2">
{paymentActionModel.more.map((kind) =>
renderPaymentActionButton(kind, kind === "cancel" ? "destructive" : "secondary"),
)}
</div>
</details>
) : undefined}
summary={(
<>
<p>批次:{selectedBatch.batchNo}</p>
<p>金额:{formatMoney(selectedBatch.totalAmount)}</p>
</>
)}
testId="payment-batch-action-panel"
/>
</BusinessDetailAside>
</BusinessDetailWorkspace>
{paymentActionModel.primary || paymentActionModel.more.length ? (
<div
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur lg:hidden"
data-testid="payment-batch-mobile-actions"
>
<div className="mobile-sticky-action-grid">
{paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : <span />}
{paymentActionModel.more.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="secondary">更多</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{paymentActionModel.more.map((kind) => (
<DropdownMenuItem
key={kind}
disabled={paymentActionDisabled(kind)}
className={kind === "cancel" ? "text-red-600 focus:text-red-600" : undefined}
onSelect={() => activatePaymentAction(kind)}
>
{paymentActionLabel(kind)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
) : null}
</div>
) : ( ) : (
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div> <div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
) )
+60 -80
查看文件
@@ -47,7 +47,7 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog"; import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard"; import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary"; import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress"; import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { import {
@@ -60,6 +60,12 @@ import {
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero"; import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { PageScaffold } from "../components/patterns/PageScaffold"; import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { import {
@@ -1028,30 +1034,6 @@ export function ReimbursementDetailPage() {
); );
} }
if (detail.status === "reviewing") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-workflow-progress-panel">看审批进度</a>
</Button>
);
}
if (detail.status === "approved") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-processing-progress-panel">看处理进度</a>
</Button>
);
}
if (detail.status === "paid") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-processing-progress-panel">查看付款结果</a>
</Button>
);
}
return null; return null;
})(); })();
@@ -1105,16 +1087,10 @@ export function ReimbursementDetailPage() {
<StatusBadge status={detail.status} /> <StatusBadge status={detail.status} />
</> </>
)} )}
actions={(
<div className="flex flex-wrap gap-2" data-testid="reimbursement-first-screen-actions">
{primaryAction}
{secondaryActions}
</div>
)}
/> />
<div className="mx-auto w-full max-w-5xl space-y-5"> <BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<div className="space-y-5"> <BusinessDetailMain>
<SectionPanel title="报销概况"> <SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview"> <div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div> <div>
@@ -1227,35 +1203,17 @@ export function ReimbursementDetailPage() {
) : null} ) : null}
</div> </div>
{showSecondaryDetail ? (
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
<div className="detail-command-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h4 className="text-base font-semibold text-zinc-900">更多</h4>
<div className="flex flex-wrap gap-2">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
</div>
</div>
</div>
) : null}
</SectionPanel> </SectionPanel>
<SectionPanel title="处理进度">
<ProcessingProgress
progress={processingProgress}
ariaLabel="报销处理进度"
testId="reimbursement-workflow-progress"
targetLinkLabel="查看所在付款批次"
/>
</SectionPanel>
<SectionPanel title="报销明细"> <SectionPanel title="报销明细">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items"> <div className="space-y-4" data-testid="reimbursement-items">
@@ -1574,27 +1532,49 @@ export function ReimbursementDetailPage() {
</div> </div>
</SectionPanel> </SectionPanel>
<SectionPanel title="处理进度"> </BusinessDetailMain>
<div id="reimbursement-processing-progress-panel">
<ReimbursementProcessingProgress progress={processingProgress} />
</div>
</SectionPanel>
</div>
<div className="space-y-5"> <BusinessDetailAside>
<ReimbursementActions <BusinessDetailActionPanel
status={detail.status} status={<StatusBadge status={detail.status} />}
showSubmitAction={false} primaryAction={primaryAction}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""} secondaryActions={secondaryActions}
submitDisabled={blockStandardChecks.length > 0} moreActions={showSecondaryDetail ? (
submitHint="" <div className="grid gap-2" data-testid="reimbursement-secondary-detail">
submitNotice="" {detail.status !== "paid" ? (
isPending={mutation.isPending} <Button asChild variant="secondary">
onSubmit={() => void handleSubmitAction()} <Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} </Button>
/> ) : null}
</div> {canDeleteReimbursement ? (
</div> <Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
) : null}
testId="reimbursement-action-panel"
>
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
/>
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}> <Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
<DialogContent <DialogContent
+1 -1
查看文件
@@ -444,7 +444,7 @@ export function WorkflowTaskDetailPage() {
<BusinessDetailAside> <BusinessDetailAside>
<BusinessDetailActionPanel <BusinessDetailActionPanel
title="处理" title="处理"
className="workflow-action-panel xl:top-20" className="workflow-action-panel lg:top-20"
secondaryActions={( secondaryActions={(
<> <>
<Button <Button
@@ -37,9 +37,7 @@ export function ReimbursementActions({
} }
return ( return (
<div id="reimbursement-action-panel" className="card-std space-y-4 p-6"> <div className="space-y-4">
<h3 className="text-base font-semibold text-zinc-900">可执行动作</h3>
{actionError ? ( {actionError ? (
<p <p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
+17
查看文件
@@ -0,0 +1,17 @@
export type ProcessingStageState = "completed" | "current" | "pending" | "exception" | "terminated";
export type ProcessingStage = {
key: string;
label: string;
state: ProcessingStageState;
actorName: string | null;
occurredAt: string | null;
result: string | null;
comment: string | null;
detail: string | null;
targetHref: string | null;
};
export type ProcessingProgress = {
stages: ProcessingStage[];
};
+2
查看文件
@@ -116,8 +116,10 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
data: { asset: { id: string } }; data: { asset: { id: string } };
}; };
const assetId = createAssetPayload.data.asset.id; const assetId = createAssetPayload.data.asset.id;
await page.setViewportSize({ width: 1024, height: 768 });
await page.goto(`/assets?id=${assetId}`); await page.goto(`/assets?id=${assetId}`);
await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 }); await expect(page.getByTestId("asset-name")).toHaveValue(assetName, { timeout: 30_000 });
await expect(page.locator(".detail-action-panel")).toHaveCSS("position", "sticky");
const assetRow = page.getByTestId("asset-row").filter({ hasText: assetName }).first(); const assetRow = page.getByTestId("asset-row").filter({ hasText: assetName }).first();
if (await assetRow.isVisible().catch(() => false)) { if (await assetRow.isVisible().catch(() => false)) {
+1 -1
查看文件
@@ -163,7 +163,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
}); });
await receiptUploadResponsePromise; await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("e2e-bank-receipt.pdf"); await expect(receiptCard).toContainText("e2e-bank-receipt.pdf");
await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click(); await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成"); await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click(); await page.getByTestId("action-confirm-submit").click();
await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 }); await expect(executedBatchDetailCard).toContainText("已付款", { timeout: 30_000 });
@@ -1140,8 +1140,10 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0); await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0); await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10"); await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
await expect(page.getByTestId("reimbursement-first-screen-actions").locator("a,button")).toHaveCount(2); await expect(page.getByTestId("reimbursement-first-screen-actions")).toHaveCount(0);
await expect(page.getByTestId("reimbursement-first-screen-actions")).toContainText("看审批进度"); await expect(page.getByTestId("reimbursement-action-panel")).toBeVisible();
await expect(page.getByTestId("reimbursement-action-panel").getByRole("button", { name: "撤回" })).toBeVisible();
await expect(page.getByRole("link", { name: /看审批进度|看处理进度|查看付款结果/ })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0); await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
await page.getByTestId("reimbursement-more-toggle").click(); await page.getByTestId("reimbursement-more-toggle").click();
await expect(page.getByTestId("reimbursement-secondary-detail")).toBeVisible(); await expect(page.getByTestId("reimbursement-secondary-detail")).toBeVisible();
@@ -1149,12 +1151,15 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true }); await page.screenshot({ path: screenshotPath("01-pc-reimbursement-detail.png"), fullPage: true });
await page.goto("/workflow/tasks/task-wave3"); await page.goto("/workflow/tasks/task-wave3");
await page.setViewportSize({ width: 1024, height: 768 });
await expect(page.getByRole("heading", { name: "审批详情" })).toBeVisible(); await expect(page.getByRole("heading", { name: "审批详情" })).toBeVisible();
await expect(page.getByTestId("workflow-task-next-action")).toHaveCount(0); await expect(page.getByTestId("workflow-task-next-action")).toHaveCount(0);
await expect(page.getByText("审批动作")).toHaveCount(0); await expect(page.getByText("审批动作")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toBeVisible(); await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05"); await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
await expect(page.locator(".workflow-action-panel")).toHaveCSS("position", "sticky");
await page.setViewportSize({ width: 1440, height: 960 });
await page.getByTestId("workflow-task-reimbursement-detail-link").click(); await page.getByTestId("workflow-task-reimbursement-detail-link").click();
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/); await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
await expect(page.getByTestId("reimbursement-overview")).toBeVisible(); await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
@@ -638,10 +638,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(pcSectionOrder).toEqual([ expect(pcSectionOrder).toEqual([
"reimbursement-overview", "reimbursement-overview",
"reimbursement-workflow-progress",
"reimbursement-items", "reimbursement-items",
"reimbursement-materials", "reimbursement-materials",
"reimbursement-workflow-progress",
]); ]);
await expect(page.locator('a[href="#reimbursement-workflow-progress-panel"]')).toHaveCount(0);
await expect(page.locator('a[href="#reimbursement-processing-progress-panel"]')).toHaveCount(0);
await expect(page.getByText("ERP")).toHaveCount(0); await expect(page.getByText("ERP")).toHaveCount(0);
await page.getByTestId("reimbursement-invoice-add").click(); await page.getByTestId("reimbursement-invoice-add").click();
@@ -791,9 +793,9 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(mobileSectionOrder).toEqual([ expect(mobileSectionOrder).toEqual([
"mobile-reimbursement-detail-focus", "mobile-reimbursement-detail-focus",
"mobile-reimbursement-processing-section",
"mobile-reimbursement-items", "mobile-reimbursement-items",
"mobile-reimbursement-materials", "mobile-reimbursement-materials",
"mobile-reimbursement-processing-section",
]); ]);
const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first(); const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0); await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
@@ -155,7 +155,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName }); const managerBatchDetail = page.getByTestId("payment-batch-detail-panel").filter({ hasText: batchName });
await expect(managerBatchDetail).toBeVisible(); await expect(managerBatchDetail).toBeVisible();
await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name); await expect(page.getByTestId("payment-batch-items-panel")).toContainText(EMPLOYEE.name);
await managerBatchDetail.getByRole("button", { name: "复核通过" }).click(); await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "复核通过" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核"); await expect(page.getByTestId("action-confirm-dialog")).toContainText("独立复核");
await page.getByTestId("action-confirm-submit").click(); await page.getByTestId("action-confirm-submit").click();
await expect(managerBatchDetail).toContainText("1/1"); await expect(managerBatchDetail).toContainText("1/1");
@@ -170,8 +170,8 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
const receiptCard = page.getByTestId("payment-batch-receipt-panel"); const receiptCard = page.getByTestId("payment-batch-receipt-panel");
await expect(receiptCard).toBeVisible(); await expect(receiptCard).toBeVisible();
await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible(); await expect(page.getByTestId("payment-batch-item-payment-fields")).toBeVisible();
await expect(executedBatchDetailCard.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible(); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "上传银行回单" })).toBeVisible();
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
const receiptUploadResponsePromise = page.waitForResponse((response) => { const receiptUploadResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
return response.request().method() === "POST" && return response.request().method() === "POST" &&
@@ -185,7 +185,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
}); });
await receiptUploadResponsePromise; await receiptUploadResponsePromise;
await expect(receiptCard).toContainText("r78-bank-receipt.pdf"); await expect(receiptCard).toContainText("r78-bank-receipt.pdf");
await expect(executedBatchDetailCard.getByRole("button", { name: "登记付款完成" })).toBeVisible(); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
const executeBatchResponsePromise = page.waitForResponse((response) => { const executeBatchResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url()); const url = new URL(response.url());
@@ -193,7 +193,7 @@ test("E2E 报销付款批次主链路:提交 -> 审批 -> 创建批次 -> 复
url.pathname === `/api/payment-batches/${batchId}/execute` && url.pathname === `/api/payment-batches/${batchId}/execute` &&
response.ok(); response.ok();
}); });
await executedBatchDetailCard.getByRole("button", { name: "登记付款完成" }).click(); await page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" }).click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成"); await expect(page.getByTestId("action-confirm-dialog")).toContainText("登记付款完成");
await page.getByTestId("action-confirm-submit").click(); await page.getByTestId("action-confirm-submit").click();
await executeBatchResponsePromise; await executeBatchResponsePromise;
@@ -80,21 +80,50 @@ function reimbursementListItem() {
}; };
} }
function reimbursementDetail() { function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft") {
const workflowTaskId = "workflow-task-action-confirm";
return { return {
...reimbursementListItem(), ...reimbursementListItem(),
status,
submitterId: me.id, submitterId: me.id,
ownerId: me.id, ownerId: me.id,
departmentId: "department-action-confirm", departmentId: "department-action-confirm",
department: { id: "department-action-confirm", name: "合成部门" }, department: { id: "department-action-confirm", name: "合成部门" },
submittedAt: null, submittedAt: null,
travelSummary: { isTravel: false, purpose: null, location: null, startDate: null, endDate: null },
projectId: null, projectId: null,
linkedTravelRequestId: null, linkedTravelRequestId: null,
linkedTravelRequest: null, linkedTravelRequest: null,
owner: { id: me.id, employee_no: me.employeeNo, name: me.name }, owner: { id: me.id, employee_no: me.employeeNo, name: me.name },
submitter: { id: me.id, employee_no: me.employeeNo, name: me.name }, submitter: { id: me.id, employee_no: me.employeeNo, name: me.name },
items: [], items: [
invoices: [], {
id: "reimbursement-item-action-confirm",
expenseTypeId: "expense-type-action-confirm",
expenseDate: "2026-05-15",
amount: "88.00",
description: "合成报销动作回归",
expenseType: { id: "expense-type-action-confirm", code: "office", name: "办公费", category: "office", enabled: true },
},
],
invoices: [
{
id: "invoice-action-confirm",
status: "bound",
invoiceCode: "SYNTHETIC",
invoiceNumber: "ACTION001",
invoiceDate: "2026-05-15",
sellerName: "合成供应方",
buyerName: "合成购买方",
documentCategory: "vat_invoice",
documentSubtype: null,
totalAmount: "88.00",
originalName: "合成动作发票.pdf",
mimeType: "application/pdf",
fileSize: 1024,
linkedItemId: "reimbursement-item-action-confirm",
},
],
attachments: [ attachments: [
{ {
id: reimbursementAttachmentId, id: reimbursementAttachmentId,
@@ -117,14 +146,53 @@ function reimbursementDetail() {
}, },
], ],
budgetHint: null, budgetHint: null,
pendingWorkflow: null, pendingWorkflow: status === "reviewing" ? {
instanceId: "workflow-instance-action-confirm",
stepId: "workflow-step-action-confirm",
task: {
id: workflowTaskId,
step_name: "负责人复核",
assignee_id: "another-manager",
assignee_role: "manager",
},
} : null,
workflowProgress: null, workflowProgress: null,
processingProgress: { stages: [] },
approvalHistory: [], approvalHistory: [],
riskFlags: [], riskFlags: [],
}; };
} }
function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" = "reviewed", highRisk = false) { type SyntheticPaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
function paymentProcessingProgress(status: SyntheticPaymentBatchStatus, hasReceipt: boolean, requiresBankReceipt = true) {
const reviewState = status === "draft" ? "current" : status === "cancelled" ? "exception" : "completed";
const paymentState = status === "executed"
? "completed"
: status === "failed"
? "exception"
: status === "cancelled"
? "terminated"
: status === "reviewed"
? "current"
: "pending";
return {
stages: [
{ key: "payment_batch_created", label: "批次创建", state: "completed", actorName: me.name, occurredAt: "2026-05-15T08:00:00.000Z", result: "已创建", comment: null, detail: null, targetHref: null },
{ key: "payment_batch_review", label: "付款复核", state: reviewState, actorName: status === "draft" ? null : me.name, occurredAt: status === "draft" ? null : "2026-05-15T08:10:00.000Z", result: status === "draft" ? "待复核" : status === "cancelled" ? "批次已取消" : "通过", comment: status === "cancelled" ? "合成取消原因" : null, detail: status === "draft" ? "待非制单人复核付款" : null, targetHref: null },
{ key: "payment_and_receipt", label: "付款完成", state: paymentState, actorName: ["executed", "failed"].includes(status) ? me.name : null, occurredAt: ["executed", "failed"].includes(status) ? "2026-05-15T08:30:00.000Z" : null, result: status === "executed" ? "已付款" : status === "failed" ? "付款失败" : status === "cancelled" ? "已终止" : "待付款", comment: null, detail: status === "reviewed" ? (!requiresBankReceipt ? "等待财务登记付款完成,非审批环节" : hasReceipt ? "银行回单已上传,等待财务登记付款完成,非审批环节" : "等待财务线下付款并上传回单,非审批环节") : null, targetHref: null },
],
};
}
function paymentBatch(
status: SyntheticPaymentBatchStatus = "reviewed",
highRisk = false,
hasReceipt = true,
paymentChannel: "bank" | "cash" = "bank",
canExportPaymentOrder = false,
paymentOrderExportCount = 0,
) {
return { return {
id: paymentBatchId, id: paymentBatchId,
batchNo: "PAY-ACTION-001", batchNo: "PAY-ACTION-001",
@@ -132,10 +200,14 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
name: "R7E 高风险付款确认", name: "R7E 高风险付款确认",
status, status,
defaultPaymentDate: "2026-05-15", defaultPaymentDate: "2026-05-15",
defaultPaymentChannel: "bank", defaultPaymentChannel: paymentChannel,
voucherPrefix: "R7E", voucherPrefix: "R7E",
totalAmount: "88.00", totalAmount: "88.00",
highRisk, highRisk,
paymentAccount: null,
paymentOrderExportCount,
canExportPaymentOrder,
paymentOrderExportBlockers: [],
reviewRequirement: { reviewRequirement: {
thresholdAmount: "1000.00", thresholdAmount: "1000.00",
requiredReviewCount: 1, requiredReviewCount: 1,
@@ -158,6 +230,7 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
cancelledAt: null, cancelledAt: null,
cancelledBy: null, cancelledBy: null,
cancelledReason: null, cancelledReason: null,
processingProgress: paymentProcessingProgress(status, hasReceipt, paymentChannel === "bank"),
items: [ items: [
{ {
id: "payment-batch-item-action-confirm", id: "payment-batch-item-action-confirm",
@@ -172,7 +245,7 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
payeeName: "合成收款员工", payeeName: "合成收款员工",
bankName: "合成银行", bankName: "合成银行",
bankAccountMasked: "6222********5678", bankAccountMasked: "6222********5678",
status: "pending", status: status === "executed" ? "executed" : status === "failed" ? "failed" : status === "cancelled" ? "cancelled" : "pending",
failureCode: null, failureCode: null,
failureMessage: null, failureMessage: null,
createdAt: "2026-05-15T08:00:00.000Z", createdAt: "2026-05-15T08:00:00.000Z",
@@ -185,16 +258,21 @@ function paymentBatch(status: "draft" | "reviewed" | "executed" | "cancelled" =
async function mockSystemActionApis( async function mockSystemActionApis(
page: Page, page: Page,
options: { options: {
batchStatus?: "draft" | "reviewed" | "executed" | "cancelled"; batchStatus?: SyntheticPaymentBatchStatus;
hasReceipt?: boolean; hasReceipt?: boolean;
receiptQueryError?: boolean; receiptQueryError?: boolean;
highRisk?: boolean; highRisk?: boolean;
paymentChannel?: "bank" | "cash";
canExportPaymentOrder?: boolean;
paymentOrderExportCount?: number;
reimbursementStatus?: "draft" | "reviewing" | "paid";
meOverride?: Partial<typeof me>; meOverride?: Partial<typeof me>;
} = {}, } = {},
) { ) {
const currentMe = { ...me, ...options.meOverride }; const currentMe = { ...me, ...options.meOverride };
const batchStatus = options.batchStatus ?? "reviewed"; const batchStatus = options.batchStatus ?? "reviewed";
let hasReceipt = options.hasReceipt ?? true; let hasReceipt = options.hasReceipt ?? true;
let currentReimbursementStatus = options.reimbursementStatus ?? "draft";
const calls = { const calls = {
deleteReimbursement: 0, deleteReimbursement: 0,
deleteReimbursementAttachment: 0, deleteReimbursementAttachment: 0,
@@ -205,6 +283,10 @@ async function mockSystemActionApis(
voidPaymentReceipt: 0, voidPaymentReceipt: 0,
attachmentDeleteReasons: [] as string[], attachmentDeleteReasons: [] as string[],
receiptVoidReasons: [] as string[], receiptVoidReasons: [] as string[],
submitReimbursement: 0,
withdrawReimbursement: 0,
remindWorkflow: 0,
withdrawComments: [] as string[],
}; };
await page.route((url) => url.pathname.startsWith("/api/"), async (route) => { await page.route((url) => url.pathname.startsWith("/api/"), async (route) => {
@@ -288,7 +370,45 @@ async function mockSystemActionApis(
} }
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") { if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
await fulfillJson(route, reimbursementDetail()); await fulfillJson(route, reimbursementDetail(currentReimbursementStatus));
return;
}
if (path === `/api/reimbursements/${reimbursementId}/precheck` && request.method() === "POST") {
await fulfillJson(route, {
entityType: "reimbursement",
entityId: reimbursementId,
decision: "pass",
matchedBudgetControlRule: null,
budgetHint: null,
standardChecks: [],
warnings: [],
blockingReasons: [],
riskFlags: [],
nextActions: ["submit"],
});
return;
}
if (path === `/api/reimbursements/${reimbursementId}/submit` && request.method() === "POST") {
calls.submitReimbursement += 1;
currentReimbursementStatus = "reviewing";
await fulfillJson(route, { reimbursementId, status: "reviewing" });
return;
}
if (path === `/api/reimbursements/${reimbursementId}/withdraw` && request.method() === "POST") {
calls.withdrawReimbursement += 1;
const body = request.postDataJSON() as { comment?: string };
calls.withdrawComments.push(body.comment ?? "");
currentReimbursementStatus = "draft";
await fulfillJson(route, { reimbursementId, status: "draft" });
return;
}
if (path === "/api/workflow/tasks/workflow-task-action-confirm/remind" && request.method() === "POST") {
calls.remindWorkflow += 1;
await fulfillJson(route, { taskId: "workflow-task-action-confirm", remindedAt: "2026-05-15T09:00:00.000Z" });
return; return;
} }
@@ -333,7 +453,14 @@ async function mockSystemActionApis(
} }
if (path === "/api/payment-batches" && request.method() === "GET") { if (path === "/api/payment-batches" && request.method() === "GET") {
const listItem = paymentBatch(batchStatus, options.highRisk); const listItem = paymentBatch(
batchStatus,
options.highRisk,
hasReceipt,
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
);
await fulfillJson(route, { await fulfillJson(route, {
items: [{ items: [{
...listItem, ...listItem,
@@ -350,7 +477,16 @@ async function mockSystemActionApis(
if (path === `/api/payment-batches/${paymentBatchId}` && request.method() === "GET") { if (path === `/api/payment-batches/${paymentBatchId}` && request.method() === "GET") {
calls.paymentBatchDetail += 1; calls.paymentBatchDetail += 1;
await fulfillJson(route, { item: paymentBatch(batchStatus, options.highRisk) }); await fulfillJson(route, {
item: paymentBatch(
batchStatus,
options.highRisk,
hasReceipt,
options.paymentChannel,
options.canExportPaymentOrder,
options.paymentOrderExportCount,
),
});
return; return;
} }
@@ -412,7 +548,7 @@ async function mockSystemActionApis(
if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") { if (path === `/api/payment-batches/${paymentBatchId}/execute` && request.method() === "POST") {
calls.executePaymentBatch += 1; calls.executePaymentBatch += 1;
await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk) }); await fulfillJson(route, { item: paymentBatch("reviewed", options.highRisk, hasReceipt, options.paymentChannel) });
return; return;
} }
@@ -455,6 +591,59 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await expect.poll(() => calls.exportReimbursements).toBe(1); await expect.poll(() => calls.exportReimbursements).toBe(1);
}); });
test("报销详情迁移后保留提交与删除的状态、确认和刷新合同", async ({ page }) => {
const calls = await mockSystemActionApis(page, {
reimbursementStatus: "draft",
meOverride: { permissions: [...me.permissions, "expense.submit"] },
});
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const actionPanel = page.getByTestId("reimbursement-action-panel");
await expect(actionPanel.getByRole("button", { name: "提交报销" })).toBeVisible();
await actionPanel.getByRole("button", { name: "提交报销" }).click();
await expect.poll(() => calls.submitReimbursement).toBe(1);
await expect(page.getByText("审批中", { exact: true }).first()).toBeVisible();
await page.unrouteAll({ behavior: "wait" });
const deleteCalls = await mockSystemActionApis(page, { reimbursementStatus: "draft" });
await page.goto(`/reimbursements/${reimbursementId}`);
await page.getByTestId("reimbursement-more-toggle").click();
await page.getByTestId("reimbursement-delete-draft").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("删除后无法恢复");
expect(deleteCalls.deleteReimbursement).toBe(0);
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => deleteCalls.deleteReimbursement).toBe(1);
await expect(page).toHaveURL(/\/reimbursements\?status=draft/);
});
test("报销详情迁移后保留催办与撤回理由及刷新合同", async ({ page }) => {
const calls = await mockSystemActionApis(page, { reimbursementStatus: "reviewing" });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const actionPanel = page.getByTestId("reimbursement-action-panel");
await actionPanel.getByRole("button", { name: "催办审批" }).click();
await expect.poll(() => calls.remindWorkflow).toBe(1);
await page.getByTestId("reimbursement-withdraw-comment").fill("合成撤回原因");
await page.getByTestId("reimbursement-withdraw").click();
await expect.poll(() => calls.withdrawReimbursement).toBe(1);
expect(calls.withdrawComments).toEqual(["合成撤回原因"]);
await expect(page.getByText("草稿", { exact: true }).first()).toBeVisible();
});
test("报销详情迁移后已支付状态仍可进入打印页", async ({ page }) => {
await mockSystemActionApis(page, { reimbursementStatus: "paid" });
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const printLink = page.getByTestId("reimbursement-action-panel").getByRole("link", { name: "打印 / PDF" });
await expect(printLink).toBeVisible();
await printLink.click();
await expect(page).toHaveURL(new RegExp(`/reimbursements/${reimbursementId}/print$`));
});
test("付款批次执行必须确认后才调用接口", async ({ page }) => { test("付款批次执行必须确认后才调用接口", async ({ page }) => {
const calls = await mockSystemActionApis(page); const calls = await mockSystemActionApis(page);
page.on("dialog", (dialog) => { page.on("dialog", (dialog) => {
@@ -478,12 +667,15 @@ test.describe("R7E 全站高风险操作二次确认", () => {
}); });
test("银行回单上传前后只显示一个付款主动作", async ({ page }) => { test("银行回单上传前后只显示一个付款主动作", async ({ page }) => {
const calls = await mockSystemActionApis(page, { hasReceipt: false }); const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true });
await login(page, user); await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByRole("button", { name: "上传银行回单" }).first()).toBeVisible(); const actionPanel = page.getByTestId("payment-batch-action-panel");
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(1); await expect(actionPanel.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await actionPanel.getByText("更多", { exact: true }).click();
await expect(actionPanel.getByRole("button", { name: "下载银行制单" })).toBeVisible();
await expect(page.getByTestId("payment-batch-mobile-actions")).toBeHidden();
await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0);
@@ -494,32 +686,102 @@ test.describe("R7E 全站高风险操作二次确认", () => {
}); });
await expect.poll(() => calls.uploadPaymentReceipt).toBe(1); await expect.poll(() => calls.uploadPaymentReceipt).toBe(1);
await expect(page.getByRole("button", { name: "登记付款完成" })).toBeVisible(); await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(page.getByTestId("payment-batch-detail-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByTestId("payment-batch-detail-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
}); });
test("移动端固定操作区复用同一动作模型并在上传后原地切换", async ({ page }) => {
const calls = await mockSystemActionApis(page, { hasReceipt: false, canExportPaymentOrder: true });
await page.setViewportSize({ width: 390, height: 844 });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const mobileActions = page.getByTestId("payment-batch-mobile-actions");
await expect(mobileActions).toBeVisible();
await expect(page.getByTestId("payment-batch-action-panel")).toBeHidden();
await expect(mobileActions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
await mobileActions.getByRole("button", { name: "更多" }).click();
await expect(page.getByRole("menuitem", { name: "下载银行制单" })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByTestId("payment-batch-receipt-file-input").setInputFiles({
name: "移动端合成银行回单.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\nmobile synthetic receipt\n%%EOF\n", "utf8"),
});
await expect.poll(() => calls.uploadPaymentReceipt).toBe(1);
await expect(mobileActions.getByRole("button", { name: "登记付款完成" })).toBeVisible();
await expect(mobileActions.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
});
test("付款详情按处理顺序展示并统一人民币金额格式", async ({ page }) => { test("付款详情按处理顺序展示并统一人民币金额格式", async ({ page }) => {
await mockSystemActionApis(page, { hasReceipt: false, highRisk: true }); await mockSystemActionApis(page, { hasReceipt: false, highRisk: true });
await page.setViewportSize({ width: 1024, height: 768 });
await login(page, user); await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByTestId("payment-batch-detail-panel")).toBeVisible(); await expect(page.getByTestId("payment-batch-detail-panel")).toBeVisible();
const sectionOrder = await page.locator([ const sectionOrder = await page.locator([
'[data-testid="payment-batch-detail-panel"]', '[data-testid="payment-batch-detail-panel"]',
'[data-testid="payment-batch-current-processing"]', '[data-testid="payment-batch-processing-panel"]',
'[data-testid="payment-batch-items-panel"]', '[data-testid="payment-batch-items-panel"]',
'[data-testid="payment-batch-receipt-panel"]', '[data-testid="payment-batch-receipt-panel"]',
].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); ].join(",")).evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")));
expect(sectionOrder).toEqual([ expect(sectionOrder).toEqual([
"payment-batch-detail-panel", "payment-batch-detail-panel",
"payment-batch-current-processing", "payment-batch-processing-panel",
"payment-batch-items-panel", "payment-batch-items-panel",
"payment-batch-receipt-panel", "payment-batch-receipt-panel",
]); ]);
await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00"); await expect(page.getByTestId("payment-batch-detail-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("¥1,000.00");
await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00"); await expect(page.getByTestId("payment-batch-items-panel")).toContainText("¥88.00");
await expect(page.getByTestId("payment-batch-current-processing")).toContainText("线下付款并上传银行回单"); await expect(page.getByTestId("payment-batch-processing-panel")).toContainText("等待财务线下付款并上传回单");
const responsiveContract = await page.locator(".detail-workspace-grid").evaluate((workspace) => {
const actionPanel = workspace.querySelector(".detail-action-panel");
return {
gridColumns: getComputedStyle(workspace).gridTemplateColumns,
actionPosition: actionPanel ? getComputedStyle(actionPanel).position : null,
};
});
expect(responsiveContract.gridColumns.split(" ").filter(Boolean)).toHaveLength(2);
expect(responsiveContract.actionPosition).toBe("sticky");
});
test("付款三阶段时间线覆盖五种状态并正确终止", async ({ page }) => {
const cases: Array<[SyntheticPaymentBatchStatus, string]> = [
["draft", "待复核"],
["reviewed", "待付款"],
["executed", "已付款"],
["failed", "付款失败"],
["cancelled", "已终止"],
];
for (const [status, expected] of cases) {
await page.unrouteAll({ behavior: "wait" });
await mockSystemActionApis(page, { batchStatus: status, hasReceipt: status === "executed" });
await page.context().clearCookies();
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
const progress = page.getByTestId("payment-batch-processing-progress");
await expect(progress).toBeVisible();
await expect(progress).toContainText("批次创建");
await expect(progress).toContainText("付款复核");
await expect(progress).toContainText("付款完成");
await expect(progress).toContainText(expected);
}
});
test("非银行渠道直接等待登记完成且不引导回单或制单", async ({ page }) => {
await mockSystemActionApis(page, { batchStatus: "reviewed", hasReceipt: false, paymentChannel: "cash" });
await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByTestId("payment-batch-processing-progress")).toContainText("等待财务登记付款完成");
await expect(page.getByTestId("payment-batch-processing-progress")).not.toContainText("银行制单");
await expect(page.getByTestId("payment-batch-processing-progress")).not.toContainText("上传回单");
await expect(page.getByTestId("payment-batch-receipt-panel")).toContainText("当前付款渠道不要求银行回单");
await expect(page.getByRole("button", { name: "上传银行回单" })).toHaveCount(0);
await expect(page.getByTestId("payment-batch-action-panel").getByRole("button", { name: "登记付款完成" })).toBeVisible();
}); });
test("付款详情按批次状态渐进展示收款依据和财务动作", async ({ page }) => { test("付款详情按批次状态渐进展示收款依据和财务动作", async ({ page }) => {
@@ -626,7 +888,7 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await login(page, user); await login(page, user);
await page.goto(`/payment-batches?id=${paymentBatchId}`); await page.goto(`/payment-batches?id=${paymentBatchId}`);
await expect(page.getByText("制单人不能复核自己的批次")).toBeVisible(); await expect(page.getByTestId("payment-batch-processing-progress")).toContainText("待非制单人复核付款");
await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "复核通过", exact: true })).toHaveCount(0);
}); });
+1
查看文件
@@ -755,3 +755,4 @@ OPOLICY1 是正式启用前的制度收口层。它不新增一套业务流程
- 新建银行付款批次会冻结付款方和收款方账号。旧批次没有冻结密文,永久不支持制单,仍按人工转账、上传回单、登记付款完成的路径处理。 - 新建银行付款批次会冻结付款方和收款方账号。旧批次没有冻结密文,永久不支持制单,仍按人工转账、上传回单、登记付款完成的路径处理。
- 银行制单仅支持建设银行本行,生成真实 BIFF8 `.xls`。重新下载不会作废旧文件;若旧文件已导入网银,不得重复提交。 - 银行制单仅支持建设银行本行,生成真实 BIFF8 `.xls`。重新下载不会作废旧文件;若旧文件已导入网银,不得重复提交。
- 制单下载不是付款证据。企业网银付款完成后仍须上传银行回单,才能登记付款完成。 - 制单下载不是付款证据。企业网银付款完成后仍须上传银行回单,才能登记付款完成。
- 下一笔新快照批次首次验证时,先把“验证制单”和“使用制单付款”分开:先导出文件,用 Excel 打开并由固定版本 `xlrd` 独立读回;只有建设银行网银明确提供“仅校验、不提交”步骤时,才进入网银做格式校验。若没有该步骤,不把文件导入网银,实际付款仍走人工转账、上传回单和登记完成。
+70 -12
查看文件
@@ -19,6 +19,11 @@ const { enqueueReimbursementJournalExport } = require("../services/integrations/
const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl"); const { ensureRiskFlag, buildPersistedRiskFlag, toMoneyNumber, toMoneyString } = require("../services/feeControl");
const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto"); const { decryptStringOrThrow, maskBankAccount } = require("../services/crypto");
const { generatePaymentOrderXls, PaymentOrderXlsError } = require("../services/paymentOrderXls"); const { generatePaymentOrderXls, PaymentOrderXlsError } = require("../services/paymentOrderXls");
const {
buildPaymentProcessingSemantics,
requiresBankReceipt,
resolveItemPaymentChannel,
} = require("../services/paymentProcessingProgress");
const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage"); const { deleteObject, openReadStream, StorageNotFoundError, writeBuffer } = require("../services/storage");
const { const {
buildAttachmentContentDisposition, buildAttachmentContentDisposition,
@@ -138,16 +143,8 @@ function mapReceiptAttachment(item) {
}; };
} }
function resolveItemPaymentChannel(batch, item) {
return item.payment_channel_override ?? batch.default_payment_channel ??
(batch.entity_type === "reimbursement" ? "bank" : null);
}
async function ensureBankReceiptForExecutableBatch(tx, batch) { async function ensureBankReceiptForExecutableBatch(tx, batch) {
const requiresBankReceipt = (batch.items ?? []).some((item) => if (!requiresBankReceipt(batch)) {
["pending", "failed"].includes(item.status) && resolveItemPaymentChannel(batch, item) === "bank",
);
if (!requiresBankReceipt) {
return; return;
} }
@@ -817,9 +814,64 @@ function mapBatch(batch, openRiskFlags = [], legacyPayees = new Map(), options =
}; };
} }
function isoDateTimeOrNull(value) {
if (!value) return null;
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
function buildPaymentBatchProcessingProgress(batch, actorNames) {
const semantics = buildPaymentProcessingSemantics({
status: batch.status,
createdAt: batch.created_at,
reviewedAt: batch.reviewed_at,
executedAt: batch.executed_at,
cancelledAt: batch.cancelled_at,
cancelledReason: batch.cancelled_reason,
requiresBankReceipt: requiresBankReceipt(batch),
receiptCount: batch._count?.receipt_attachments ?? 0,
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
});
const actorIds = {
creator: batch.created_by,
reviewer: batch.reviewed_by,
executor: batch.executed_by,
canceller: batch.cancelled_by,
};
return {
stages: [semantics.created, semantics.review, semantics.payment].map((stage) => ({
key: stage.key,
label: stage.label,
state: stage.state,
actorName: stage.actorSource ? actorNames.get(actorIds[stage.actorSource]) ?? null : null,
occurredAt: isoDateTimeOrNull(stage.occurredAt),
result: stage.result,
comment: stage.comment,
detail: stage.detail,
targetHref: null,
})),
};
}
async function mapBatchForResponse(tx, batch, openRiskFlags, config) { async function mapBatchForResponse(tx, batch, openRiskFlags, config) {
const legacyPayees = await loadLegacyPayees(tx, batch, config); const legacyPayees = await loadLegacyPayees(tx, batch, config);
return mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true }); const actorIds = Array.from(new Set([
batch.created_by,
batch.reviewed_by,
batch.executed_by,
batch.cancelled_by,
].filter(Boolean)));
const actors = actorIds.length
? await tx.employee.findMany({
where: { id: { in: actorIds } },
select: { id: true, name: true },
})
: [];
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
return {
...mapBatch(batch, openRiskFlags, legacyPayees, { includePayee: true }),
processingProgress: buildPaymentBatchProcessingProgress(batch, actorNames),
};
} }
async function listOpenRiskFlagsForBatch(tx, batch) { async function listOpenRiskFlagsForBatch(tx, batch) {
@@ -2183,7 +2235,10 @@ async function paymentBatchesRoutes(fastify) {
reviewed_by: request.employee.id, reviewed_by: request.employee.id,
updated_by: request.employee.id, updated_by: request.employee.id,
}, },
include: { items: true }, include: {
items: true,
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
},
}); });
await tx.auditLog.create({ await tx.auditLog.create({
@@ -2359,7 +2414,10 @@ async function paymentBatchesRoutes(fastify) {
executed_by: request.employee.id, executed_by: request.employee.id,
updated_by: request.employee.id, updated_by: request.employee.id,
}, },
include: { items: true }, include: {
items: true,
_count: { select: { receipt_attachments: { where: { archived_at: null } } } },
},
}); });
await tx.auditLog.create({ await tx.auditLog.create({
+50 -55
查看文件
@@ -33,6 +33,11 @@ const {
resolveEntityPrecheck, resolveEntityPrecheck,
} = require("../services/feeControl"); } = require("../services/feeControl");
const { issueDocumentNumber } = require("../services/documentSequence"); const { issueDocumentNumber } = require("../services/documentSequence");
const {
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
buildPaymentProcessingSemantics,
requiresBankReceipt,
} = require("../services/paymentProcessingProgress");
const { openReadStream, readFileBuffer, StorageNotFoundError, writeBuffer } = require("../services/storage"); const { openReadStream, readFileBuffer, StorageNotFoundError, writeBuffer } = require("../services/storage");
const { const {
MaterialPreviewError, MaterialPreviewError,
@@ -333,8 +338,8 @@ const PROCESSING_STAGE_DEFINITIONS = Object.freeze([
{ key: "submitted", label: "申请提交" }, { key: "submitted", label: "申请提交" },
{ key: "manager_approval", label: "主管审批" }, { key: "manager_approval", label: "主管审批" },
{ key: "finance_review", label: "财务复核" }, { key: "finance_review", label: "财务复核" },
{ key: "payment_batch_review", label: "付款复核" }, PAYMENT_PROCESSING_STAGE_DEFINITIONS.review,
{ key: "payment_and_receipt", label: "付款完成" }, PAYMENT_PROCESSING_STAGE_DEFINITIONS.payment,
]); ]);
function isoDateTimeOrNull(value) { function isoDateTimeOrNull(value) {
@@ -514,68 +519,48 @@ function buildProcessingProgress({ reimbursement, workflowInstance, paymentBatch
? { state: "current", result: "待安排", detail: "待财务安排付款" } ? { state: "current", result: "待安排", detail: "待财务安排付款" }
: { detail: "待财务安排付款" }); : { detail: "待财务安排付款" });
paymentStage.detail = "待付款复核后由财务登记,非审批环节"; paymentStage.detail = "待付款复核后由财务登记,非审批环节";
} else if (batch.status === "draft") { } else {
Object.assign(batchReviewStage, { const semantics = buildPaymentProcessingSemantics({
state: "current", status: batch.status,
result: "待复核", createdAt: batch.created_at,
detail: "待非制单人复核付款", reviewedAt: batch.reviewed_at,
targetHref: batchTargetHref, executedAt: batch.executed_at,
cancelledAt: batch.cancelled_at,
cancelledReason: batch.cancelled_reason,
requiresBankReceipt: requiresBankReceipt(batch),
receiptCount: batch._count?.receipt_attachments ?? 0,
paymentOrderExportCount: batch.payment_order_export_count ?? 0,
}); });
Object.assign(paymentStage, { const actorIds = {
detail: "待付款复核通过后由财务登记,非审批环节", reviewer: batch.reviewed_by,
targetHref: batchTargetHref, executor: batch.executed_by,
}); canceller: batch.cancelled_by,
} else if (["reviewed", "executed", "failed"].includes(batch.status)) { };
Object.assign(batchReviewStage, { for (const [target, source] of [
state: "completed", [batchReviewStage, semantics.review],
actorName: paymentActorNames.get(batch.reviewed_by) ?? null, [paymentStage, semantics.payment],
occurredAt: isoDateTimeOrNull(batch.reviewed_at), ]) {
result: "通过", Object.assign(target, {
detail: batch.status === "reviewed" ? "付款复核已通过" : null, state: source.state,
targetHref: batchTargetHref, actorName: source.actorSource
}); ? paymentActorNames.get(actorIds[source.actorSource]) ?? null
if (batch.status === "reviewed") { : null,
Object.assign(paymentStage, { occurredAt: isoDateTimeOrNull(source.occurredAt),
state: "current", result: source.result,
result: "待付款", comment: source.comment,
detail: "待财务完成付款并登记回单,非审批环节", detail: source.detail,
targetHref: batchTargetHref,
});
} else if (batch.status === "failed") {
Object.assign(paymentStage, {
state: "exception",
actorName: paymentActorNames.get(batch.executed_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.executed_at),
result: "付款失败",
detail: "付款失败,待财务重新处理,非审批环节",
targetHref: batchTargetHref, targetHref: batchTargetHref,
}); });
} }
} else if (batch.status === "cancelled") {
Object.assign(batchReviewStage, {
state: "exception",
actorName: paymentActorNames.get(batch.cancelled_by) ?? null,
occurredAt: isoDateTimeOrNull(batch.cancelled_at),
result: "批次已取消",
comment: batch.cancelled_reason ?? null,
detail: "付款安排已取消,待财务重新处理",
targetHref: batchTargetHref,
});
Object.assign(paymentStage, {
detail: "待财务重新安排付款,非审批环节",
targetHref: batchTargetHref,
});
} }
if (batch?.status === "executed" || reimbursement.status === "paid") { if (!batch && reimbursement.status === "paid") {
const receiptCount = batch?._count?.receipt_attachments ?? 0;
Object.assign(paymentStage, { Object.assign(paymentStage, {
state: "completed", state: "completed",
actorName: reimbursement.paid_by_employee?.name ?? paymentActorNames.get(batch?.executed_by) ?? null, actorName: reimbursement.paid_by_employee?.name ?? null,
occurredAt: isoDateTimeOrNull(reimbursement.paid_at ?? batch?.executed_at), occurredAt: isoDateTimeOrNull(reimbursement.paid_at),
result: "已付款", result: "已付款",
detail: receiptCount > 0 ? "付款和回单已留存" : "付款已完成,回单待留存", detail: "付款已完成,回单待留存",
targetHref: batchTargetHref,
}); });
} }
@@ -2591,6 +2576,10 @@ async function reimbursementRoutes(fastify) {
select: { select: {
id: true, id: true,
status: true, status: true,
entity_type: true,
default_payment_channel: true,
payment_order_export_count: true,
created_at: true,
created_by: true, created_by: true,
reviewed_at: true, reviewed_at: true,
reviewed_by: true, reviewed_by: true,
@@ -2599,6 +2588,12 @@ async function reimbursementRoutes(fastify) {
cancelled_at: true, cancelled_at: true,
cancelled_by: true, cancelled_by: true,
cancelled_reason: true, cancelled_reason: true,
items: {
select: {
status: true,
payment_channel_override: true,
},
},
_count: { _count: {
select: { receipt_attachments: { where: { archived_at: null } } }, select: { receipt_attachments: { where: { archived_at: null } } },
}, },
@@ -0,0 +1,135 @@
const PAYMENT_PROCESSING_STAGE_DEFINITIONS = Object.freeze({
created: Object.freeze({ key: "payment_batch_created", label: "批次创建" }),
review: Object.freeze({ key: "payment_batch_review", label: "付款复核" }),
payment: Object.freeze({ key: "payment_and_receipt", label: "付款完成" }),
});
function resolveItemPaymentChannel(batch, item) {
return item?.payment_channel_override ?? batch?.default_payment_channel ??
(batch?.entity_type === "reimbursement" ? "bank" : null);
}
function requiresBankReceipt(batch) {
return (batch?.items ?? []).some((item) =>
["pending", "failed"].includes(item.status) && resolveItemPaymentChannel(batch, item) === "bank",
);
}
function buildStage(definition, overrides = {}) {
return {
...definition,
state: "pending",
result: null,
detail: null,
comment: null,
actorSource: null,
occurredAt: null,
...overrides,
};
}
function buildPaymentProcessingSemantics({
status,
createdAt = null,
reviewedAt = null,
executedAt = null,
cancelledAt = null,
cancelledReason = null,
requiresBankReceipt: bankReceiptRequired = false,
receiptCount = 0,
paymentOrderExportCount = 0,
}) {
const created = buildStage(PAYMENT_PROCESSING_STAGE_DEFINITIONS.created, {
state: "completed",
result: "已创建",
actorSource: "creator",
occurredAt: createdAt,
});
const review = buildStage(PAYMENT_PROCESSING_STAGE_DEFINITIONS.review);
const payment = buildStage(PAYMENT_PROCESSING_STAGE_DEFINITIONS.payment, {
detail: "待付款复核通过后由财务登记,非审批环节",
});
if (status === "draft") {
Object.assign(review, {
state: "current",
result: "待复核",
detail: "待非制单人复核付款",
});
} else if (["reviewed", "executed", "failed"].includes(status)) {
Object.assign(review, {
state: "completed",
result: "通过",
detail: status === "reviewed" ? "付款复核已通过" : null,
actorSource: "reviewer",
occurredAt: reviewedAt,
});
if (status === "reviewed") {
const hasReceipt = Number(receiptCount) > 0;
let detail = "付款复核已通过,等待财务登记付款完成,非审批环节";
if (bankReceiptRequired && !hasReceipt) {
detail = Number(paymentOrderExportCount) > 0
? "已生成银行制单,等待付款完成并上传回单,非审批环节"
: "等待财务线下付款并上传回单,非审批环节";
} else if (bankReceiptRequired) {
detail = "银行回单已上传,等待财务登记付款完成,非审批环节";
}
Object.assign(payment, {
state: "current",
result: "待付款",
detail,
});
} else if (status === "executed") {
Object.assign(payment, {
state: "completed",
result: "已付款",
detail: Number(receiptCount) > 0 ? "付款和回单已留存" : "付款已登记完成",
actorSource: "executor",
occurredAt: executedAt,
});
} else {
Object.assign(payment, {
state: "exception",
result: "付款失败",
detail: "付款登记失败,待财务核对后重试,非审批环节",
actorSource: "executor",
occurredAt: executedAt,
});
}
} else if (status === "cancelled") {
const cancellation = {
state: "exception",
result: "批次已取消",
detail: "付款安排已取消,流程已终止",
comment: cancelledReason,
actorSource: "canceller",
occurredAt: cancelledAt,
};
if (reviewedAt) {
Object.assign(review, {
state: "completed",
result: "通过",
actorSource: "reviewer",
occurredAt: reviewedAt,
});
Object.assign(payment, cancellation);
} else {
Object.assign(review, cancellation);
Object.assign(payment, {
state: "terminated",
result: "已终止",
detail: null,
});
}
}
return { created, review, payment };
}
module.exports = {
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
buildPaymentProcessingSemantics,
requiresBankReceipt,
resolveItemPaymentChannel,
};
@@ -263,8 +263,8 @@ function buildPrisma(options = {}) {
...(options.paymentAccount ?? {}), ...(options.paymentAccount ?? {}),
}, },
employees: options.employees ?? [ employees: options.employees ?? [
{ id: MAKER_ID, employee_no: "FIN001", status: "active", roles: [{ role_code: "finance" }] }, { id: MAKER_ID, employee_no: "FIN001", name: "制单财务", status: "active", roles: [{ role_code: "finance" }] },
{ id: REVIEWER_ID, employee_no: "MGR001", status: "active", roles: [{ role_code: "manager" }] }, { id: REVIEWER_ID, employee_no: "MGR001", name: "复核负责人", status: "active", roles: [{ role_code: "manager" }] },
], ],
invoice: { invoice: {
linked_reimbursement_id: REIMBURSEMENT_ID, linked_reimbursement_id: REIMBURSEMENT_ID,
@@ -983,6 +983,7 @@ describe("payment batch reimbursement routes", () => {
bankAccountMasked: null, bankAccountMasked: null,
}); });
expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson"); expect(listResponse.json().data.items[0].items[0]).not.toHaveProperty("snapshotJson");
expect(listResponse.json().data.items[0]).not.toHaveProperty("processingProgress");
expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1); expect(tx.reimbursementForm.findMany).toHaveBeenCalledTimes(1);
const detailResponse = await app.inject({ const detailResponse = await app.inject({
@@ -1002,6 +1003,38 @@ describe("payment batch reimbursement routes", () => {
{ code: "legacy_payee_snapshot_missing", entityCode: "BX-20260429-0001" }, { code: "legacy_payee_snapshot_missing", entityCode: "BX-20260429-0001" },
])); ]));
expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson"); expect(detailResponse.json().data.item.items[0]).not.toHaveProperty("snapshotJson");
expect(detailResponse.json().data.item.processingProgress.stages).toEqual([
expect.objectContaining({ key: "payment_batch_created", label: "批次创建", state: "completed", actorName: "制单财务" }),
expect.objectContaining({ key: "payment_batch_review", label: "付款复核", state: "completed", actorName: "复核负责人" }),
expect.objectContaining({ key: "payment_and_receipt", label: "付款完成", state: "current", detail: expect.stringContaining("回单已上传") }),
]);
expect(JSON.stringify(detailResponse.json().data.item.processingProgress)).not.toContain("snapshot");
expect(JSON.stringify(detailResponse.json().data.item.processingProgress)).not.toContain(MAKER_ID);
expect(JSON.stringify(detailResponse.json().data.item.processingProgress)).not.toContain(REVIEWER_ID);
await app.close();
});
it("does not ask a reviewed cash batch for a bank receipt or payment order", async () => {
const { app } = await buildApp({
batch: {
default_payment_channel: "cash",
payment_order_export_count: 3,
},
});
const response = await app.inject({
method: "GET",
url: `/api/payment-batches/${BATCH_ID}`,
headers: { authorization: "Bearer test" },
});
expect(response.statusCode).toBe(200);
const paymentStage = response.json().data.item.processingProgress.stages[2];
expect(paymentStage).toMatchObject({ state: "current", result: "待付款" });
expect(paymentStage.detail).toContain("等待财务登记付款完成");
expect(paymentStage.detail).not.toContain("回单");
expect(paymentStage.detail).not.toContain("制单");
await app.close(); await app.close();
}); });
@@ -599,16 +599,20 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
it.each([ it.each([
["draft", "current", "pending", "待付款复核通过后由财务登记,非审批环节"], ["draft", "current", "pending", "待付款复核通过后由财务登记,非审批环节"],
["reviewed", "completed", "current", "待财务完成付款并登记回单,非审批环节"], ["reviewed", "completed", "current", "等待财务线下付款并上传回单,非审批环节"],
["executed", "completed", "completed", "付款和回单已留存"], ["executed", "completed", "completed", "付款和回单已留存"],
["failed", "completed", "exception", "付款失败,待财务重新处理,非审批环节"], ["failed", "completed", "exception", "付款登记失败,待财务核对后重试,非审批环节"],
["cancelled", "exception", "pending", "待财务重新安排付款,非审批环节"], ["cancelled", "exception", "terminated", null],
])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => { ])("maps %s payment batch without exposing a batch link to ordinary applicants", async (status, reviewState, paymentState, detail) => {
const paymentBatchItem = { const paymentBatchItem = {
id: "batch-item-1", id: "batch-item-1",
payment_batch: { payment_batch: {
id: "batch-1", id: "batch-1",
status, status,
entity_type: "reimbursement",
default_payment_channel: "bank",
payment_order_export_count: 0,
created_at: new Date("2026-04-11T07:00:00.000Z"),
created_by: "maker-1", created_by: "maker-1",
reviewed_at: status === "draft" || status === "cancelled" ? null : new Date("2026-04-11T08:00:00.000Z"), reviewed_at: status === "draft" || status === "cancelled" ? null : new Date("2026-04-11T08:00:00.000Z"),
reviewed_by: status === "draft" || status === "cancelled" ? null : "reviewer-1", reviewed_by: status === "draft" || status === "cancelled" ? null : "reviewer-1",
@@ -617,6 +621,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
cancelled_at: status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null, cancelled_at: status === "cancelled" ? new Date("2026-04-11T09:00:00.000Z") : null,
cancelled_by: status === "cancelled" ? "reviewer-1" : null, cancelled_by: status === "cancelled" ? "reviewer-1" : null,
cancelled_reason: status === "cancelled" ? "批次资料需调整" : null, cancelled_reason: status === "cancelled" ? "批次资料需调整" : null,
items: [{ status: status === "executed" ? "executed" : "pending", payment_channel_override: null }],
_count: { receipt_attachments: status === "executed" ? 1 : 0 }, _count: { receipt_attachments: status === "executed" ? 1 : 0 },
}, },
}; };
@@ -650,6 +655,10 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
payment_batch: { payment_batch: {
id: "batch-1", id: "batch-1",
status: "draft", status: "draft",
entity_type: "reimbursement",
default_payment_channel: "bank",
payment_order_export_count: 0,
created_at: new Date("2026-04-11T07:00:00.000Z"),
created_by: "maker-1", created_by: "maker-1",
reviewed_at: null, reviewed_at: null,
reviewed_by: null, reviewed_by: null,
@@ -658,6 +667,7 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
cancelled_at: null, cancelled_at: null,
cancelled_by: null, cancelled_by: null,
cancelled_reason: null, cancelled_reason: null,
items: [{ status: "pending", payment_channel_override: null }],
_count: { receipt_attachments: 0 }, _count: { receipt_attachments: 0 },
}, },
}, },
@@ -0,0 +1,122 @@
const {
PAYMENT_PROCESSING_STAGE_DEFINITIONS,
buildPaymentProcessingSemantics,
requiresBankReceipt,
} = require("../../../src/services/paymentProcessingProgress");
const BASE = {
createdAt: new Date("2026-08-03T01:00:00.000Z"),
reviewedAt: new Date("2026-08-03T02:00:00.000Z"),
executedAt: new Date("2026-08-03T03:00:00.000Z"),
cancelledAt: new Date("2026-08-03T04:00:00.000Z"),
cancelledReason: "合成取消原因",
};
describe("paymentProcessingProgress", () => {
it("owns the three payment stage labels", () => {
expect(PAYMENT_PROCESSING_STAGE_DEFINITIONS).toEqual({
created: { key: "payment_batch_created", label: "批次创建" },
review: { key: "payment_batch_review", label: "付款复核" },
payment: { key: "payment_and_receipt", label: "付款完成" },
});
});
it.each([
["draft", "current", "pending"],
["reviewed", "completed", "current"],
["executed", "completed", "completed"],
["failed", "completed", "exception"],
])("maps %s without a second route-level state matrix", (status, reviewState, paymentState) => {
const stages = buildPaymentProcessingSemantics({ ...BASE, status });
expect(stages.review.state).toBe(reviewState);
expect(stages.payment.state).toBe(paymentState);
});
it("terminates a cancelled draft at review", () => {
const stages = buildPaymentProcessingSemantics({ ...BASE, status: "cancelled", reviewedAt: null });
expect(stages.review).toMatchObject({
state: "exception",
result: "批次已取消",
actorSource: "canceller",
comment: "合成取消原因",
});
expect(stages.payment).toMatchObject({ state: "terminated", result: "已终止", detail: null });
});
it("terminates a reviewed cancellation at payment", () => {
const stages = buildPaymentProcessingSemantics({ ...BASE, status: "cancelled" });
expect(stages.review).toMatchObject({ state: "completed", actorSource: "reviewer" });
expect(stages.payment).toMatchObject({
state: "exception",
result: "批次已取消",
actorSource: "canceller",
});
});
it.each([
[false, 0, 0, "等待财务登记付款完成"],
[true, 0, 0, "等待财务线下付款并上传回单"],
[true, 0, 1, "已生成银行制单"],
[true, 1, 1, "银行回单已上传"],
])("uses the receipt contract (%s, %s, %s)", (bankReceiptRequired, receiptCount, exportCount, text) => {
const stages = buildPaymentProcessingSemantics({
...BASE,
status: "reviewed",
requiresBankReceipt: bankReceiptRequired,
receiptCount,
paymentOrderExportCount: exportCount,
});
expect(stages.payment.detail).toContain(text);
});
it("derives receipt requirements from the same effective-channel rule", () => {
expect(requiresBankReceipt({
entity_type: "reimbursement",
default_payment_channel: "cash",
items: [{ status: "pending", payment_channel_override: null }],
})).toBe(false);
expect(requiresBankReceipt({
entity_type: "reimbursement",
default_payment_channel: "cash",
items: [{ status: "failed", payment_channel_override: "bank" }],
})).toBe(true);
expect(requiresBankReceipt({
entity_type: "reimbursement",
default_payment_channel: null,
items: [{ status: "pending", payment_channel_override: null }],
})).toBe(true);
expect(requiresBankReceipt({
entity_type: "reimbursement",
default_payment_channel: "bank",
items: [{ status: "executed", payment_channel_override: null }],
})).toBe(false);
});
it("keeps route assembly layers free of a second payment status matrix", () => {
const reimbursementsSource = fs.readFileSync(
path.resolve(__dirname, "../../../src/routes/reimbursements.js"),
"utf8",
);
const paymentBatchesSource = fs.readFileSync(
path.resolve(__dirname, "../../../src/routes/paymentBatches.js"),
"utf8",
);
const reimbursementAssembler = reimbursementsSource.slice(
reimbursementsSource.indexOf("function buildProcessingProgress("),
reimbursementsSource.indexOf("function ensureApplicantOrAdmin("),
);
const paymentBatchAssembler = paymentBatchesSource.slice(
paymentBatchesSource.indexOf("function buildPaymentBatchProcessingProgress("),
paymentBatchesSource.indexOf("async function mapBatchForResponse("),
);
for (const assembler of [reimbursementAssembler, paymentBatchAssembler]) {
expect(assembler).toContain("buildPaymentProcessingSemantics(");
expect(assembler).not.toMatch(/batch\.status\s*===|\.includes\(batch\.status\)/);
expect(assembler).not.toContain('label: "付款复核"');
expect(assembler).not.toContain('label: "付款完成"');
}
});
});
const fs = require("node:fs");
const path = require("node:path");