feat(报销): 收敛只读内容与审批上下文 [09-02-reimbursement-readonly-content-consistency]

这个提交包含在:
2026-09-02 21:13:08 +08:00
父节点 dda625b15f
当前提交 e48514d542
共修改 20 个文件,包含 740 行新增和 47 行删除
@@ -520,6 +520,9 @@ reference, not a copy of Feishu or Ant Design branding.
- Reimbursement approval actions fail closed until workflow snapshot parsing and current reimbursement evidence are ready. Historical missing budget snapshots are explained; malformed snapshots disable all decision paths.
- Mobile workflow caching uses an explicit persisted projection. Reimbursement items, invoices, attachments, amount evidence, material access data, workflow snapshot checks and shared component projections do not enter IndexedDB. Offline reimbursement approve/reject/return/draft is forbidden, while other workflow entity offline behavior remains unchanged.
- The information order is compact header and reason, expense items, invoices, supplementary attachments, real issues/differences when present, then history/progress. Normal state creates no `0 条` check card. Issue copy identifies the object, reason, impact and next step rather than exposing internal counters alone.
- Expense totals in the header, desktop table footer, mobile summary row and print view all display the server `totalAmount`. A shared workspace-model boundary may sum strictly parsed integer minor units only to classify integrity; that derived sum is never rendered or submitted. A mismatch or unparseable amount is a data-integrity failure, uses distinct non-color-only copy and fails reimbursement approval closed; normal records show no difference explanation.
- Reimbursement workflow history answers approvers “who acted, when and with what comment”. On desktop approval it appears once in the right aside after the independently sticky decision panel; the main column receives no dormant timeline slot. On mobile approval it remains after evidence and before the safe-area action surface.
- `ProcessingProgress` intentionally remains separate from workflow history. It answers applicants “where the reimbursement and payment have reached”, may include future stages and owns the single payment-batch link. Workflow history contains occurred actions only. Do not remove either as duplicate or let their shared action/status semantics conflict.
- `MobileStickyActionBar` and `MOBILE_STICKY_ACTION_PAGE_CLASS` are one contract. Add the page class only when `mobileActions` exists; omitting it lets `page-scaffold` transform capture the fixed bar, while adding it without a bar leaves false bottom padding.
- `/reimbursements/:id/print` remains a purpose-built print/PDF view. It is not a fourth interactive workbench and receives no workbench mutation.
@@ -20,7 +20,8 @@
"subtasks": [],
"children": [
"09-01-reimbursement-approval-content-workbench",
"09-01-reimbursement-form-shared-component-migration"
"09-01-reimbursement-form-shared-component-migration",
"09-02-reimbursement-readonly-content-consistency"
],
"parent": null,
"relatedFiles": [],
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,91 @@
# Design:报销只读内容一致性与审批上下文收敛
## 1. Information Classification Contract
采用来源驱动的三分法,不按页面、区块位置或当前用户分类:
```text
ReimbursementDeclaredContent(报销人申报)
├─ header / description
├─ expense items / authoritative total
├─ invoice and attachment facts
└─ declared project / travel association
ReimbursementProcessingContext(审批人或系统形成)
├─ expense-policy / budget snapshot
├─ invoice amount-evidence state / B2 result
├─ processing progress
├─ workflow history
└─ decision actions
ReimbursementArchiveMetadata(打印动作形成)
└─ QR code / printedAt / printedBy
```
字段是否出现在打印页不决定类别。打印页可以组合正文、处理上下文和归档元数据;三模式一致性只约束 `ReimbursementDeclaredContent`。
## 2. Workspace Model Boundary
保持 B-2b 的壳/适配器边界,不建立第二套页面模型。共享工作台模型增加一个明确的权威明细汇总投影,PC/移动语义组件都消费同一值:
```ts
type ReimbursementAmountSummary = {
authoritativeTotalAmount: string | null;
integrity: "ready" | "mismatch" | "unavailable";
};
```
`authoritativeTotalAmount` 只来自 reimbursement detail 的服务端 `totalAmount`。PC `tfoot`、移动汇总行和页头均格式化同一字段;任何页面或渲染组件不得用 `items.reduce()` 生成展示总额。
完整性判断只在共享模型构建边界执行一次。明细金额以严格十进制字符串解析为整数分后求和,仅生成 `integrity`,派生和不得暴露为第二个业务总额或参与提交。无法安全解析时使用 `unavailable`,不伪造 mismatch;服务端仍是业务权威。
## 3. Integrity Failure Behavior
- `ready`:只显示普通合计,不显示说明。
- `mismatch`:在明细合计附近显示“报销总额与明细汇总不一致,请联系管理员”,并作为 blocking issue 注入审批 readiness,使通过、退回、驳回、转审全部 fail-closed。
- `unavailable`:保留服务端权威总额展示,同时说明“明细金额无法完整核对,请联系管理员”,并按既有“关键内容未就绪不得审批”合同 fail-closed;它不是 mismatch,也不得显示虚构差额。
异常不提供“修改总额”入口,不改变 B2 或服务端 recompute 合同。
## 4. Desktop and Mobile Amount Presentation
PC 在现有语义表格中使用真正的 `<tfoot>`,列跨度随 edit 操作列存在与否调整;标签为“明细合计”,金额单元格保持右对齐、tabular numerals,并通过 scope/可访问文本说明这是服务端权威总额。
移动端不模拟表格,在明细卡片组末尾渲染同一 `ReimbursementAmountSummary` 的汇总行。它与页头金额允许重复,因为两者分别服务“整单识别”和“逐条核对后的闭合”,但值与可访问名称必须一致。
## 5. Approval History Placement
PC approval 由适配器组合右栏内容,不让共享壳判断模式:
```text
BusinessDetailAside
├─ sticky DecisionActionPanel
└─ WorkflowHistoryPanel
```
审批动作卡继续 sticky;历史卡位于动作之后但不并入 sticky 容器,避免 6 条历史或长意见改变动作可达性。PC 主列不再传 `timeline`。移动适配器继续把相同完整历史传入正文末尾,底部动作保持独立安全区。
不新增历史摘要,不截断最近一条意见,不保留主列隐藏副本。历史空状态仍可见,但不使用告警视觉。
## 6. Progress Versus History
`ProcessingProgress` 和 workflow timeline 不合并:
- 普通详情的进度模型面向申请人,包含未来阶段、当前阶段和唯一付款批次入口。
- 审批 timeline 面向处理人,只呈现已发生 workflow 事件及意见。
共享的是格式化与动作/状态语义,不强求同一 DOM。测试使用同一 workflow fixture 对照已发生事件,证明名称、人员、时间和结果不矛盾;未来阶段只允许出现在进度模型。
## 7. Print and Compatibility Boundary
打印页继续作为组合归档视图:明细合计仍直接显示服务端 `totalAmount`,审批记录和归档元数据保持现状。本任务不把打印页迁入工作台,也不删除它的审批记录。
不改 API、schema、migration 或状态机。现有 B2、材料查看审计、离线缓存白名单和 capability 注入全部保持。
## 8. UI/UX Constraints
沿用 `ui-ux-pro-max` 适用规则:使用语义表格元素;长历史意见可自然换行且不得溢出;异步内容预留稳定位置;金额采用本地化格式;异常不能只靠颜色。PC 1280px 无页面级横向滚动,移动 375px/横屏无内容遮挡,触控目标不小于 44×44。
## 9. Rollout Boundary
本设计经最新规划评审批准后可以进入实施,不再等待父任务 AC11。commit、push、部署继续使用独立批准门。部署后由 AC11 检查三项假设:财务是否认可明细区合计、右栏历史是否更利于决策、申请人是否混淆进度与审批历史;反馈若推翻方案,再以新事实开启修订,不现场扩 scope。
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
@@ -0,0 +1,51 @@
# Implementation Plan:报销只读内容一致性与审批上下文收敛
## Entry Gate
- [x] 用户已批准“先改进、后由 AC11 验收”的最新规划摘要,并运行 `task.py start`。
- [x] 实施前加载 `trellis-before-dev`,读取 frontend、reimbursement、workflow、privacy 和 UI QA 规约。
## Implementation Order
1. [x] 在共享工作台模型增加 `ReimbursementAmountSummary`,以服务端 `totalAmount` 作为唯一展示总额;用严格十进制分值解析生成仅用于完整性判断的状态。
2. [x] 扩展共享费用明细组件:PC 添加语义 `tfoot`,移动添加等价汇总行;edit/readonly/approval 均由同一模型注入。
3. [x] 把 mismatch/金额不可解析转为各自准确、可定位的 blocking issue,并接入审批 readiness 二次拦截;不提供手工改总额入口,不改变服务端合同。
4. [x] PC approval 将完整历史从主列移到右栏动作卡之后,动作卡保持独立 sticky;移动历史位置不变。
5. [x] 明确正文/上下文负向结构证据:三模式正文不得各自汇总金额;打印元数据不得进入工作台;审批上下文不得进入 edit/readonly 正文一致性断言。
6. [x] 更新 `docs/产品设计规则.md` 的历史布局规则:短审批流程可在 PC 右栏动作之后展示完整历史且主列不重复,移动仍在正文末尾;保留长流程使用主列的通用边界。
7. [x] 更新相关 PC/移动三模式、打印、权限、B2、离线和非报销回归;不修改真人单据。
## Verification
- [x] 纯函数:权威总额透传;`0`、小数、千位、非法金额、正常一致、mismatch、unavailable;使用整数分比较,无浮点误差。
- [x] 组件:PC `tfoot` 的 colspan 随 edit 操作列正确;移动汇总语义等价;三模式显示值一致;正常状态无额外说明。
- [x] 审批:mismatch 四动作 fail-closed;右栏动作在 6 条历史和长意见下保持可达;主列 timeline 为 0;历史只出现一次。
- [x] 处理语义:普通详情仍有 `ProcessingProgress` 与唯一付款批次入口;审批 timeline 不出现未来阶段;同一已发生事件的动作、人员、时间、结果一致。
- [x] 回归:edit 能维护、readonly 无维护、approval 无报销维护、B2 未归属恢复、打印合计/审批记录/二维码/打印元数据、移动离线白名单和非报销审批。
- [x] `cd client && npm run typecheck && npm run build`。
- [x] `node scripts/qa/check-ui-design-rules.mjs --strict` 与 `--self-test`。
- [x] 相关 Playwright:1280px、375px、横屏、三模式、长历史、完整性故障、打印回归。
- [x] `python3 ./.trellis/scripts/task.py validate 09-02-reimbursement-readonly-content-consistency`。
- [x] `node scripts/qa/check-process-contract.mjs --baseline --json`、`node scripts/qa/check-trellis-governance.mjs`、敏感内容检查、docs registry、`git diff --check`。
## Review and Delivery Boundary
- [x] 涉及 workflow 决策 readiness,实施后执行只读 review;inline 模式不派发 implement/check 子代理。
- [x] 无 schema、migration、flag、seed 或生产数据写入。
- [ ] commit、push、部署分别等待明确批准;部署后仅用公网真实 bundle + 合成 API 和生产只读事实验收。
- [ ] 本子任务部署不替代父任务 AC11 真人反馈;AC11 未完成时父任务继续保持开放。
## Rollback
- 模型或 fail-closed 回归失败:停止在本地,恢复到本任务前候选,不引入 feature flag 双轨。
- PC 历史布局影响动作可达:保留移动行为,PC 回到主列 timeline,再复核右栏结构。
- 公网验收失败:回滚应用候选,不处理业务数据。
## Local Verification Evidence
- `npm run build`:PASS(包含 typecheck 与 Vite production build)。
- `workflow-action-confirm-ui.spec.ts`:7/7 PASS,覆盖正常负向断言、两类完整性故障四动作 fail-closed、PC 六条长历史和移动 375px/横屏安全区。
- `r102-reimbursement-main-flow.spec.ts`:6/6 PASS;`r111-invoice-reimbursement-guard.spec.ts`:8/8 PASS。
- 共享模型/结构合同:13/13 PASS;移动报销离线缓存专项以 Chromium iPhone 视口 1/1 PASS。WebKit 浏览器二进制未安装,因此原 WebKit 命令为环境 PAUSED,不改测试口径。
- UI strict/self-test、task validate、process contract、Trellis governance、敏感内容、docs registry(保留既有 22 个未登记文件 warning)和 `git diff --check` 均通过。
- `bash scripts/check-minimal.sh` 的本轮相关检查通过;全量后端为 1364/1368,4 条失败均不在本任务改动面:3 条因当前 Python 缺少固定 BIFF8 依赖,1 条是 HEAD 已存在的通用审批页结构断言漂移。本任务不修改 server、付款导出或通用审批页。
@@ -0,0 +1,98 @@
# 报销只读内容一致性与审批上下文收敛
## Goal
让同一张报销单在 PC/移动、编辑/只读/审批和不同授权用户眼中保持同一份报销正文语义,同时把审批动作、审批历史和员工处理进度明确留在各自上下文中。补齐费用明细后的权威合计,避免审批人依赖页头回看或自行心算;在不复制内容、不引入第二业务金额来源的前提下保持 KISS。
## Background and Confirmed Facts
- B-2b 已部署统一工作台并通过公网真实 bundle + 合成 API 验收;旧报销审批分支已经删除,当前父任务仍等待 AC11 真人重提反馈。
- PC/移动工作台头部均显示服务端 `detail.totalAmount`,但共享费用明细组件没有明细区合计;打印页已有“合计”行,也直接显示同一个 `detail.totalAmount`。
- 服务端 `recomputeTotalAmount` 在明细变化和提交链路按明细聚合重写 `reimbursement_forms.total_amount`;`docs/报销发票附件规则.md` 明确 PC、手机和打印页只展示服务端权威总额,不提供手工填写。
- 当前审批适配器把完整 `timeline` 注入主列末尾;PC 右栏只放审批决定,移动端历史也位于内容末尾。
- 普通报销详情使用 `ProcessingProgress` 回答“我的报销/付款走到哪一步”;审批任务使用 workflow timeline 回答“谁执行了什么动作、留下什么意见”。二者服务不同问题,不视为同一组件的视觉分叉。
- 当前打印页包含三类来源不同的信息:报销人申报的报销头/事由/明细/合计/票据,审批人或系统形成的审批记录,以及打印动作形成的二维码/打印人/打印时间。页面位置不能替代信息来源成为分类依据。
## Scope
- 定义并验证报销正文、处理上下文和归档元数据的来源分类合同。
- 在统一工作台的 PC/移动明细区补充服务端权威合计,并建立仅用于完整性判断的共享状态。
- 将 PC 报销审批完整历史移入右栏动作之后且只保留一份;移动历史位置不变。
- 固化 `ProcessingProgress` 与 workflow timeline 的职责分工和一致性回归。
- 更新与上述行为直接冲突的产品体验事实和自动化验收;不改业务数据合同。
## Product Requirements
### R1 按信息来源划分正文、处理上下文和归档元数据
- 业务正文是报销人申报的内容:报销头、事由、费用明细、权威明细合计、票据与附件,以及由报销人申报的关联项目/差旅信息。PC/移动的 edit、readonly、approval 必须消费同一正文模型并逐字段一致。
- 处理上下文是审批人或系统对本次申报形成的判断与记录:提交时快照、费用标准/预算判断、票据金额证据状态、B2 检查、处理进度、审批历史和审批动作。上下文可以按模式和权限出现或省略;省略不构成正文不一致,但同一事实在不同模式出现时语义不得冲突。
- 归档元数据是打印动作形成的信息:二维码、打印时间和打印人。它只属于打印页,不进入交互工作台正文。
- 打印页是三类信息的组合交付视图,不再作为“页面内所有字段都是正文”的判据;字段归属只由来源决定。
- 布局可以响应设备变化:PC 合计使用语义表格 `tfoot`,移动端在明细卡片组末尾使用等价的“明细合计”汇总行;字段含义、金额来源和可访问名称必须一致。
- 模式只增加能力和上下文:edit 增加维护/提交能力,approval 增加快照、决策和 workflow 历史,readonly 不增加业务动作。不得为不同角色复制或重新计算正文。
### R2 明细合计只展示服务端权威总额
- 明细区合计显示统一工作台模型中的服务端 `totalAmount`,不得在 PC 表格、移动卡片或各页面用 `items.reduce()` 生成可展示的第二总额。
- 若实现完整性核对,明细派生和权威总额只能在共享模型构建边界比较一次;派生值仅用于判断完整性,不得作为报销金额展示或提交。
- 不一致不是正常业务“差额”,必须显示为数据完整性故障,文案明确为“报销总额与明细汇总不一致,请联系管理员”,并阻止审批动作;不得暗示用户可手工修正总额。
- 明细金额无法安全解析时不得伪造差额;应明确提示无法完整核对并按既有关键内容未就绪合同阻止审批动作。
- 金额比较必须使用十进制定点/分值口径,不能使用二进制浮点直接累加。
### R3 审批历史在 PC 右栏只保留一份
- PC approval 将完整 workflow 处理历史移到右侧审批上下文,位于审批动作之后;主列不再重复 timeline。
- 历史条目保留动作、处理人、时间和完整意见;正常短流程不新增“历史摘要 + 完整历史”双层表达。
- 历史增长不得把主要审批动作挤出可达区域;右栏的 sticky、滚动和长意见折行策略在设计阶段明确,并覆盖 1280px 与长退回意见。
- 移动端不复制 PC 右栏,完整历史继续位于正文之后、底部动作之前或安全区动作之前的既有阅读顺序。
### R4 处理进度与处理历史明确分工
- `ProcessingProgress` 保留在普通报销详情,回答员工“当前走到哪一步、付款到哪一步”,并继续提供唯一付款批次入口。
- workflow timeline 保留在审批任务,回答审批人“谁处理过、执行了什么动作、留下什么意见”。
- 两者可以使用不同视觉结构,但动作名称、人员、时间和状态含义不得互相矛盾;本任务不强行合并为一个组件。
### R5 先改进,再由 AC11 完成业务验收
- 用户决定不再以 AC11 真人反馈作为本子任务的实施前置;本规划经最新评审明确批准后即可 `task.py start`。
- 本子任务只改变已冻结的合计、历史位置和上下文表达,不借“先改进”继续扩充范围;commit、push、部署仍分别等待明确批准。
- 父任务 AC11 仍是最终业务验收:部署后至少记录一次员工真实重提与一次财务真实复审,核对员工提示、正文一致性、明细合计、PC 历史位置和进度/历史理解。
- 真人反馈若推翻已上线行为,必须作为新事实回到规划,不以现场猜测直接改代码;在 AC11 完成前不得归档父任务。
### R6 不扩展业务与数据范围
- 不修改 schema、migration、B1/B2、费用类型、workflow 状态机、材料权限或生产数据。
- 不操作 `0016/0017/0018`;它们仍用于父任务 AC11 的真人反馈。
- 不借本任务处理部署脚本、`.gitignore`、错误色 token、AGENTS skill 名称或其他治理债。
## Acceptance Criteria
- [x] AC1:同一合成报销数据在 PC/移动 edit、readonly、approval 中的报销头、事由、明细、合计、票据/附件和申报关联信息逐字段一致;差异仅来自已批准的能力与处理上下文。
- [x] AC2:PC 费用明细表使用 `tfoot` 显示“明细合计 + 服务端权威总额”;移动卡片组末尾显示语义等价汇总。三模式、不同用户均从同一工作台模型取值。
- [x] AC3:代码与静态检查证明 PC/移动明细组件不存在用于展示总额的 `items.reduce()` 或页面级金额汇总;展示金额与页头、打印页使用同一 `totalAmount`。
- [x] AC4:mismatch 与金额不可解析 fixture 分别触发准确故障提示并使报销审批 fail-closed;正常 fixture 不显示差额或“0 条”说明。
- [x] AC5:PC approval 的完整历史只在右栏出现一次且位于动作之后;主列 timeline 为 0。1280px、长意见和 6 条历史下动作可达、页面无横向溢出。
- [x] AC6:移动 approval 的历史仍在正文末尾,底部动作不遮挡;375px 与横屏无横向溢出,触控目标不小于 44×44。
- [x] AC7:普通详情继续使用 `ProcessingProgress`,审批继续使用 workflow timeline;付款批次入口仍只有一处,二者没有状态/动作语义冲突。测试明确证明票据本体属于正文,而票据金额证据状态属于可按模式展示的上下文。
- [x] AC8:edit 维护能力、readonly 零维护能力、approval 零报销维护能力和 B2 未归属恢复均无回归。
- [x] AC9:打印视图继续显示服务端权威合计且不获得工作台 mutation;二维码、打印人和打印时间不进入交互工作台,审批记录不被误纳入三模式正文一致性断言。
- [x] AC10:无 schema、migration、flag、seed 或生产数据写入;通过 typecheck/build、相关 Playwright、UI strict/self-test、Trellis governance 和 `git diff --check`。
- [ ] AC11:本子任务部署后,父任务记录员工真实重提和财务真实复审反馈;反馈未完成不阻止本子任务实施,但继续阻止父任务归档。
## Test Plan
- 共享模型:严格十进制金额解析、权威总额透传、正常一致、mismatch、金额不可解析和空明细边界。
- PC/移动组件:`tfoot`/移动汇总行、三模式逐字段一致、编辑操作列 colspan、正常无解释、完整性故障可访问文案。
- 审批行为:完整性故障四动作 fail-closed;PC 历史单一右栏位置、长意见与 6 条记录;移动历史和安全区动作不遮挡。
- 上下文一致性:同一 fixture 对照 `ProcessingProgress` 与 workflow timeline 的已发生事件;未来付款阶段只出现在进度模型,付款批次入口唯一。
- 回归:edit/readonly/approval capability、B2 未归属恢复、打印视图、移动离线缓存、非报销 workflow。
- 视觉与可访问性:1280px、375px、移动横屏,无页面级横向溢出;语义表格、键盘顺序、非颜色单一表达和 44×44 触控目标。
- 工程门禁:typecheck/build、相关 Playwright、UI strict/self-test、Trellis task validate/governance、process contract、docs registry、敏感内容检查和 `git diff --check`。
## Out of Scope
- 合并 `ProcessingProgress` 与 workflow timeline,或重做付款流程。
- 重新设计整张打印页、改变打印字段或归档政策;仅把它作为内容口径证据并保持回归。
- 在取得 AC11 反馈前据此宣称真人体验问题已经解决或归档父任务。
- 其他非报销 workflow 页面和历史债务。
@@ -0,0 +1,26 @@
{
"id": "reimbursement-readonly-content-consistency",
"name": "reimbursement-readonly-content-consistency",
"title": "报销只读内容一致性与审批上下文收敛",
"description": "统一报销工作台三模式的报销正文口径,补齐权威明细合计,并收敛 PC 审批历史与右侧决策上下文。",
"status": "in_progress",
"dev_type": null,
"scope": "trellis,client,docs",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-09-02",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": "09-01-reimbursement-one-page-approval-workbench",
"relatedFiles": [],
"notes": "本地实施与专项验证完成,等待 diff 复核;权威合计、两类完整性 fail-closed、PC 历史右置和进度/历史职责合同已落地。未 commit、push、部署;AC11 仍待部署后真人单据验证。",
"meta": {}
}
@@ -25,7 +25,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
{slots.status}
<span className="info-chip">报销人:{model.header.ownerName}</span>
<span className="info-chip">日期:{formatDisplayDate(model.header.reimbursementDate)}</span>
<span className="info-chip">合计:{formatMoney(model.header.totalAmount)}</span>
<span className="info-chip">合计:{formatMoney(model.amountSummary.authoritativeTotalAmount)}</span>
</>
)}
/>
@@ -36,13 +36,15 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots
{slots.beforeEvidence}
<SectionPanel title="报销内容" className="scroll-mt-24">
<div id="reimbursement-workspace-evidence">
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} />
<ReimbursementItemEvidenceList amountSummary={model.amountSummary} evidence={model.evidence} capabilities={capabilities} />
</div>
</SectionPanel>
{slots.afterEvidence}
{slots.timeline}
</BusinessDetailMain>
<BusinessDetailAside>{slots.actionPanel}</BusinessDetailAside>
<BusinessDetailAside>
{slots.actionPanel}
{slots.asideContext}
</BusinessDetailAside>
</BusinessDetailWorkspace>
</PageScaffold>
);
@@ -7,6 +7,7 @@ import type {
ReimbursementInvoiceEvidence,
ReimbursementItemEvidence,
} from "../../lib/reimbursementApprovalProjection";
import { reimbursementAmountIntegrityMessage, type ReimbursementAmountSummary } from "../../lib/reimbursementWorkspace";
export type ReimbursementEvidenceCapabilities = {
onOpenInvoice?: (invoice: ReimbursementInvoiceEvidence) => void;
@@ -21,11 +22,17 @@ export type ReimbursementEvidenceCapabilities = {
};
type Props = {
amountSummary: ReimbursementAmountSummary;
evidence: ReimbursementApprovalEvidenceProjection;
capabilities?: ReimbursementEvidenceCapabilities;
mobile?: boolean;
};
function AmountIntegrityMessage({ integrity }: Pick<ReimbursementAmountSummary, "integrity">) {
const message = reimbursementAmountIntegrityMessage(integrity);
return message ? <p className="mt-1 text-sm font-medium text-red-700" role="alert">{message}</p> : null;
}
function evidenceStatusCopy(status: ReimbursementInvoiceEvidence["amountEvidenceStatus"]) {
if (status === "ocr_failed") return "该发票的票面金额未能读取,暂时无法提交;请重新上传这张发票,或联系财务处理。";
if (status === "manual_review") return "票面金额需财务查看原件核对;这是一项审批提示,不会阻止提交。";
@@ -61,7 +68,7 @@ function ItemIssues({ item }: { item: ReimbursementItemEvidence }) {
);
}
function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
function ExpenseItemsTable({ amountSummary, items, capabilities }: { amountSummary: ReimbursementAmountSummary; items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
const editable = Boolean(capabilities.onEditItem);
return (
<div className="overflow-x-auto rounded-lg border border-zinc-200" data-testid="reimbursement-expense-items-table">
@@ -93,12 +100,22 @@ function ExpenseItemsTable({ items, capabilities }: { items: ReimbursementItemEv
);
})}
</tbody>
<tfoot className="border-t border-zinc-300 bg-zinc-50" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<tr>
<th className="px-3 py-3 text-right font-medium text-zinc-700" colSpan={4} scope="row">明细合计</th>
<td aria-label="服务端权威明细合计" className="px-3 py-3 text-right font-semibold tabular-nums text-zinc-950">
{formatMoney(amountSummary.authoritativeTotalAmount)}
</td>
<td className="px-3 py-3" colSpan={editable ? 2 : 1} />
</tr>
{amountSummary.integrity !== "ready" ? <tr><td className="px-3 pb-3" colSpan={editable ? 7 : 6}><AmountIntegrityMessage integrity={amountSummary.integrity} /></td></tr> : null}
</tfoot>
</table>
</div>
);
}
function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
function ExpenseItemCards({ amountSummary, items, capabilities }: { amountSummary: ReimbursementAmountSummary; items: ReimbursementItemEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) {
return <div className="space-y-3">{items.map((item, index) => {
const hasBlockingIssue = itemHasBlockingIssue(item);
return (
@@ -110,7 +127,12 @@ function ExpenseItemCards({ items, capabilities }: { items: ReimbursementItemEvi
{capabilities.onEditItem ? <Button className="mt-3 min-h-11 w-full" onClick={() => capabilities.onEditItem?.(item)} type="button" variant="secondary">编辑</Button> : null}
</article>
);
})}</div>;
})}
<div className="flex items-start justify-between gap-3 border-t border-zinc-200 pt-3" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<div className="min-w-0"><p className="text-sm font-medium text-zinc-700">明细合计</p><AmountIntegrityMessage integrity={amountSummary.integrity} /></div>
<p aria-label="服务端权威明细合计" className="shrink-0 font-semibold tabular-nums text-zinc-950">{formatMoney(amountSummary.authoritativeTotalAmount)}</p>
</div>
</div>;
}
function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: ReimbursementInvoiceEvidence; capabilities: ReimbursementEvidenceCapabilities; linkTargets: ReimbursementItemEvidence[] }) {
@@ -153,13 +175,21 @@ function AttachmentMaterialRow({ attachment, capabilities }: { attachment: Reimb
</div>;
}
export function ReimbursementItemEvidenceList({ evidence, capabilities = {}, mobile = false }: Props) {
export function ReimbursementItemEvidenceList({ amountSummary, evidence, capabilities = {}, mobile = false }: Props) {
const linkedInvoices = evidence.items.flatMap((item) => item.invoices);
return (
<div className="space-y-6" data-testid="reimbursement-approval-evidence">
<section className="space-y-3" data-testid="reimbursement-expense-items">
<h3 className="font-medium text-zinc-900">费用明细</h3>
{evidence.items.length ? (mobile ? <ExpenseItemCards capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable capabilities={capabilities} items={evidence.items} />) : <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>}
{evidence.items.length ? (mobile ? <ExpenseItemCards amountSummary={amountSummary} capabilities={capabilities} items={evidence.items} /> : <ExpenseItemsTable amountSummary={amountSummary} capabilities={capabilities} items={evidence.items} />) : (
<div className="space-y-3">
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">当前报销单没有可核验的明细。</p>
<div className="flex items-start justify-between gap-3 border-t border-zinc-200 pt-3" data-testid="reimbursement-amount-summary" id="reimbursement-workspace-amount-summary">
<div><p className="text-sm font-medium text-zinc-700">明细合计</p><AmountIntegrityMessage integrity={amountSummary.integrity} /></div>
<p aria-label="服务端权威明细合计" className="font-semibold tabular-nums text-zinc-950">{formatMoney(amountSummary.authoritativeTotalAmount)}</p>
</div>
</div>
)}
</section>
<section className="space-y-4" data-testid="approval-materials">
<div className="flex flex-wrap items-center justify-between gap-2"><h3 className="font-medium text-zinc-900">票据与附件</h3>{capabilities.onOpenMaterialBundle ? <Button className="min-h-11" onClick={capabilities.onOpenMaterialBundle} size="sm" type="button" variant="secondary">查看完整材料</Button> : null}</div>
@@ -27,7 +27,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
<section className="mobile-card mobile-focus-card mobile-page-stack">
<div>
<p className="text-xs text-zinc-500">报销合计</p>
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.header.totalAmount)}</p>
<p className="mt-1 text-3xl font-semibold text-zinc-950">{formatMoney(model.amountSummary.authoritativeTotalAmount)}</p>
</div>
<div className="mobile-info-grid">
<div className="mobile-list-card">
@@ -48,7 +48,7 @@ export function ReimbursementMobileWorkspaceShell({ model, capabilities, slots,
{slots.beforeEvidence}
<section className="mobile-card scroll-mt-20" data-testid="reimbursement-workspace-evidence" id="reimbursement-workspace-evidence">
<h2 className="mb-3 text-base font-semibold text-zinc-900">报销内容</h2>
<ReimbursementItemEvidenceList evidence={model.evidence} capabilities={capabilities} mobile />
<ReimbursementItemEvidenceList amountSummary={model.amountSummary} evidence={model.evidence} capabilities={capabilities} mobile />
</section>
{slots.afterEvidence}
{slots.timeline}
+71 -4
查看文件
@@ -7,7 +7,6 @@ export type ReimbursementWorkspaceHeaderModel = {
displayCode: string;
ownerName: string;
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
};
@@ -17,8 +16,14 @@ export type ReimbursementWorkspaceIssueAnchor = {
severity: "blocking" | "review" | "info";
};
export type ReimbursementAmountSummary = {
authoritativeTotalAmount: string | null;
integrity: "ready" | "mismatch" | "unavailable";
};
export type ReimbursementWorkspaceModel = {
header: ReimbursementWorkspaceHeaderModel;
amountSummary: ReimbursementAmountSummary;
evidence: ReimbursementApprovalEvidenceProjection;
issueAnchors: ReimbursementWorkspaceIssueAnchor[];
};
@@ -31,27 +36,69 @@ export type ReimbursementWorkspaceSlots = {
afterEvidence?: ReactNode;
timeline?: ReactNode;
actionPanel?: ReactNode;
asideContext?: ReactNode;
mobileActions?: ReactNode;
transientFeedback?: ReactNode;
};
export function buildReimbursementWorkspaceHeader(
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id">,
detail: Pick<ReimbursementDetail, "formNumber" | "owner" | "reimbursementDate" | "description" | "id">,
): ReimbursementWorkspaceHeaderModel {
return {
displayCode: resolveReimbursementDisplayCode(detail),
ownerName: detail.owner?.name ?? "-",
reimbursementDate: detail.reimbursementDate,
totalAmount: detail.totalAmount,
description: detail.description,
};
}
export function parseMoneyToMinorUnits(value: string | null | undefined): bigint | null {
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(value)) return null;
const [whole, fraction = ""] = value.split(".");
return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"));
}
export function buildReimbursementAmountSummary(
authoritativeTotalAmount: string | null,
items: Array<{ amount: string }>,
): ReimbursementAmountSummary {
const authoritativeMinorUnits = parseMoneyToMinorUnits(authoritativeTotalAmount);
const itemMinorUnits = items.map((item) => parseMoneyToMinorUnits(item.amount));
if (authoritativeMinorUnits === null || itemMinorUnits.some((value) => value === null)) {
return { authoritativeTotalAmount, integrity: "unavailable" };
}
const derivedMinorUnits = itemMinorUnits.reduce<bigint>((sum, value) => sum + (value ?? 0n), 0n);
return {
authoritativeTotalAmount,
integrity: authoritativeMinorUnits === derivedMinorUnits ? "ready" : "mismatch",
};
}
export function reimbursementAmountIntegrityMessage(integrity: ReimbursementAmountSummary["integrity"]) {
if (integrity === "mismatch") return "报销总额与明细汇总不一致,请联系管理员";
if (integrity === "unavailable") return "明细金额无法完整核对,请联系管理员";
return null;
}
export function buildReimbursementWorkspaceIssueAnchors(
evidence: ReimbursementApprovalEvidenceProjection,
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
options: {
amountIntegrity?: ReimbursementAmountSummary["integrity"];
snapshotMissing?: boolean;
snapshotInvalid?: boolean;
} = {},
): ReimbursementWorkspaceIssueAnchor[] {
const anchors: ReimbursementWorkspaceIssueAnchor[] = [];
const amountIntegrityMessage = options.amountIntegrity
? reimbursementAmountIntegrityMessage(options.amountIntegrity)
: null;
if (amountIntegrityMessage) {
anchors.push({
id: "reimbursement-workspace-amount-summary",
label: amountIntegrityMessage,
severity: "blocking",
});
}
if (options.snapshotInvalid) {
anchors.push({ id: "reimbursement-workspace-snapshot", label: "提交记录无法读取", severity: "blocking" });
} else if (options.snapshotMissing) {
@@ -94,3 +141,23 @@ export function buildReimbursementWorkspaceIssueAnchors(
}
return anchors;
}
export function buildReimbursementWorkspaceModel(
detail: Pick<
ReimbursementDetail,
"formNumber" | "owner" | "reimbursementDate" | "totalAmount" | "description" | "id"
>,
evidence: ReimbursementApprovalEvidenceProjection,
options: { snapshotMissing?: boolean; snapshotInvalid?: boolean } = {},
): ReimbursementWorkspaceModel {
const amountSummary = buildReimbursementAmountSummary(detail.totalAmount, evidence.items);
return {
header: buildReimbursementWorkspaceHeader(detail),
amountSummary,
evidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(evidence, {
...options,
amountIntegrity: amountSummary.integrity,
}),
};
}
@@ -60,7 +60,7 @@ import { Textarea } from "../components/ui/textarea";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
@@ -909,11 +909,7 @@ export function MobileReimbursementDetailPage() {
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = {
header: buildReimbursementWorkspaceHeader(detail),
evidence: workspaceEvidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
};
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
const workspaceCapabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
+2 -6
查看文件
@@ -84,7 +84,7 @@ import {
} from "./reimbursement-detail/ReimbursementItemEditor";
import { buildPrecheckDialogMessage } from "../lib/precheck";
import { buildReimbursementApprovalEvidence } from "../lib/reimbursementApprovalProjection";
import { buildReimbursementWorkspaceHeader, buildReimbursementWorkspaceIssueAnchors } from "../lib/reimbursementWorkspace";
import { buildReimbursementWorkspaceModel } from "../lib/reimbursementWorkspace";
import {
focusFirstInvoiceGuardIssue,
parseReimbursementInvoiceGuardIssues,
@@ -1042,11 +1042,7 @@ export function ReimbursementDetailPage() {
budgetCheckStatus: "not_applicable",
parseStatus: "ready",
});
const workspaceModel = {
header: buildReimbursementWorkspaceHeader(detail),
evidence: workspaceEvidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(workspaceEvidence),
};
const workspaceModel = buildReimbursementWorkspaceModel(detail, workspaceEvidence);
const workspaceCapabilities = {
onOpenInvoice: (invoice: { id: string }) => openInvoiceMutation.mutate(invoice.id),
onOpenAttachment: (attachment: { id: string }) => {
@@ -36,10 +36,7 @@ import { approvalRoleLabel, workflowActionLabel } from "../../lib/approvalDispla
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../../lib/attachmentGovernance";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import { buildReimbursementApprovalEvidence } from "../../lib/reimbursementApprovalProjection";
import {
buildReimbursementWorkspaceHeader,
buildReimbursementWorkspaceIssueAnchors,
} from "../../lib/reimbursementWorkspace";
import { buildReimbursementWorkspaceModel, reimbursementAmountIntegrityMessage } from "../../lib/reimbursementWorkspace";
import { resolveErrorMessage as resolveUserErrorMessage } from "../../lib/resolveErrorMessage";
import { isWecomWebView } from "../../lib/runtimeContext";
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../../lib/workflowActionConfirm";
@@ -121,16 +118,17 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
}, [reimbursementDetailQuery.data, snapshot]);
const model = useMemo(() => {
if (!reimbursementDetailQuery.data || !evidence) return null;
return {
header: buildReimbursementWorkspaceHeader(reimbursementDetailQuery.data),
evidence,
issueAnchors: buildReimbursementWorkspaceIssueAnchors(evidence, {
snapshotInvalid: snapshot?.parseStatus === "invalid",
snapshotMissing: snapshot?.budgetCheckStatus === "missing",
}),
};
return buildReimbursementWorkspaceModel(reimbursementDetailQuery.data, evidence, {
snapshotInvalid: snapshot?.parseStatus === "invalid",
snapshotMissing: snapshot?.budgetCheckStatus === "missing",
});
}, [evidence, reimbursementDetailQuery.data, snapshot?.budgetCheckStatus, snapshot?.parseStatus]);
const decisionReady = Boolean(online && model && snapshot?.parseStatus === "ready");
const decisionReady = Boolean(
online
&& model
&& model.amountSummary.integrity === "ready"
&& snapshot?.parseStatus === "ready",
);
const allowedActions = useMemo(() => new Set(detail.allowedActions), [detail.allowedActions]);
const canTransfer = allowedActions.has("transfer") && hasPermission(permissions, "expense.transfer");
const approverQuery = useQuery({
@@ -176,7 +174,13 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
const actionMutation = useMutation({
mutationFn: async (action: "approve" | "reject" | "return" | "transfer") => {
if (!decisionReady) throw new Error("完整报销内容或提交时检查尚未就绪,暂时不能处理");
if (!decisionReady) {
throw new Error(
model
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,暂时不能处理"
: "完整报销内容或提交时检查尚未就绪,暂时不能处理",
);
}
if (action === "approve") return approveWorkflowTask(taskId);
if (action === "reject") return rejectWorkflowTask(taskId, rejectComment.trim());
if (action === "return") return returnWorkflowTask(taskId, returnComment.trim());
@@ -205,7 +209,11 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
function requestAction(action: WorkflowConfirmAction) {
if (!decisionReady) {
setActionError(online ? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。" : "报销审批需联网核对完整报销内容后才能处理。");
setActionError(!online
? "报销审批需联网核对完整报销内容后才能处理。"
: model
? reimbursementAmountIntegrityMessage(model.amountSummary.integrity) ?? "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。"
: "完整报销内容或提交时检查尚未就绪,请重试加载后再决定。");
return;
}
const comment = action === "reject" ? rejectComment : action === "return" ? returnComment : "";
@@ -322,7 +330,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
);
const timeline = (
<SectionPanel title="处理历史">
<div className="detail-timeline">
<div className="detail-timeline" data-testid="reimbursement-workflow-history">
{detail.history.length ? detail.history.map((item) => (
<div className="detail-timeline-item" key={item.id}>
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)} · {item.actorName || item.actorEmployeeNo}</div>
@@ -433,8 +441,8 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
backAction: <Button asChild variant="ghost"><Link replace to={backTarget}>返回审批列表</Link></Button>,
beforeEvidence: detail.task.status !== "pending" ? <p className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
timeline,
actionPanel,
asideContext: timeline,
}}
/>
{dialogs}
@@ -87,9 +87,12 @@ test("B-2b 报销工作台双端共享内容合同且 task detail 只由路由
expect(shell).toContain("<ReimbursementItemEvidenceList");
expect(shell.indexOf("slots.beforeEvidence")).toBeLessThan(shell.indexOf("<ReimbursementItemEvidenceList"));
expect(shell.indexOf("<ReimbursementItemEvidenceList")).toBeLessThan(shell.indexOf("slots.afterEvidence"));
expect(shell.indexOf("slots.afterEvidence")).toBeLessThan(shell.indexOf("slots.timeline"));
}
expect(desktopShell).not.toContain("slots.timeline");
expect(desktopShell.indexOf("slots.actionPanel")).toBeLessThan(desktopShell.indexOf("slots.asideContext"));
expect(mobileShell.indexOf("slots.afterEvidence")).toBeLessThan(mobileShell.indexOf("slots.timeline"));
expect(desktopRoute.match(/fetchWorkflowTaskDetail\(/g)).toHaveLength(1);
expect(mobileRoute.match(/fetchWorkflowTaskDetail\(/g)).toHaveLength(1);
expect(approvalAdapter).not.toContain("fetchWorkflowTaskDetail");
@@ -415,6 +415,8 @@ test.describe("R102 reimbursement main flow", () => {
await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-desktop-workspace")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-expense-items-table").getByRole("columnheader")).toHaveText(["序号", "费用类型", "发生日期", "费用说明", "金额", "票据", "操作"]);
await expect(page.getByTestId("approval-item-evidence")).toContainText("未关联");
await expect(page.getByTestId("approval-item-evidence")).toContainText("这条明细尚无对应票据");
@@ -451,6 +453,8 @@ test.describe("R102 reimbursement main flow", () => {
await page.getByTestId("mobile-reimbursement-card").getByRole("link").click();
await expect(page.getByTestId("reimbursement-approval-evidence").getByTestId("approval-item-evidence")).toHaveCount(1);
await expect(page.getByTestId("reimbursement-mobile-workspace")).toContainText("¥168.30");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥168.30");
});
test("管理员查看他人报销时保持只读并可在移动端切换全部报销", async ({ page }) => {
@@ -0,0 +1,149 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { expect, test } from "@playwright/test";
import { buildReimbursementApprovalEvidence } from "../../src/lib/reimbursementApprovalProjection";
import {
buildReimbursementAmountSummary,
parseMoneyToMinorUnits,
} from "../../src/lib/reimbursementWorkspace";
function source(relativePath: string) {
return readFileSync(path.resolve(process.cwd(), relativePath), "utf8");
}
test("报销金额使用整数分判断完整性且不产生展示用第二总额", () => {
expect(parseMoneyToMinorUnits("0")).toBe(0n);
expect(parseMoneyToMinorUnits("0.1")).toBe(10n);
expect(parseMoneyToMinorUnits("1234.56")).toBe(123456n);
expect(parseMoneyToMinorUnits("1,234.56")).toBeNull();
expect(parseMoneyToMinorUnits("0.001")).toBeNull();
expect(parseMoneyToMinorUnits("NaN")).toBeNull();
expect(parseMoneyToMinorUnits(null)).toBeNull();
expect(buildReimbursementAmountSummary("0.30", [{ amount: "0.1" }, { amount: "0.20" }])).toEqual({
authoritativeTotalAmount: "0.30",
integrity: "ready",
});
expect(buildReimbursementAmountSummary("1000.00", [{ amount: "400" }, { amount: "600.00" }])).toEqual({
authoritativeTotalAmount: "1000.00",
integrity: "ready",
});
expect(buildReimbursementAmountSummary("100.00", [{ amount: "99.99" }])).toEqual({
authoritativeTotalAmount: "100.00",
integrity: "mismatch",
});
expect(buildReimbursementAmountSummary("100.00", [{ amount: "invalid" }])).toEqual({
authoritativeTotalAmount: "100.00",
integrity: "unavailable",
});
expect(buildReimbursementAmountSummary("0.00", [])).toEqual({
authoritativeTotalAmount: "0.00",
integrity: "ready",
});
});
test("PC 与移动明细只展示共享模型中的服务端权威合计", () => {
const evidenceList = source("src/components/reimbursements/ReimbursementItemEvidenceList.tsx");
const desktopDetail = source("src/pages/ReimbursementDetailPage.tsx");
const mobileDetail = source("src/pages/MobileReimbursementDetailPage.tsx");
const approvalAdapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
const desktopShell = source("src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx");
const mobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
expect(evidenceList).toContain("<tfoot");
expect(evidenceList).toContain("amountSummary.authoritativeTotalAmount");
expect(evidenceList).not.toContain("items.reduce(");
expect(desktopShell).toContain("model.amountSummary.authoritativeTotalAmount");
expect(mobileShell).toContain("model.amountSummary.authoritativeTotalAmount");
expect(desktopShell).not.toContain("model.header.totalAmount");
expect(mobileShell).not.toContain("model.header.totalAmount");
for (const consumer of [desktopDetail, mobileDetail, approvalAdapter]) {
expect(consumer).toContain("buildReimbursementWorkspaceModel(");
expect(consumer).not.toContain("buildReimbursementAmountSummary(");
}
});
test("PC 审批历史只进入右栏且移动历史保持正文末尾", () => {
const desktopShell = source("src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx");
const mobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
const approvalAdapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
expect(desktopShell).not.toContain("slots.timeline");
expect(desktopShell.indexOf("slots.actionPanel")).toBeLessThan(desktopShell.indexOf("slots.asideContext"));
expect(mobileShell.indexOf("slots.afterEvidence")).toBeLessThan(mobileShell.indexOf("slots.timeline"));
expect(approvalAdapter).toContain("asideContext: timeline");
expect(approvalAdapter.match(/^\s+timeline,$/gm)).toHaveLength(1);
});
test("申请人进度与审批历史保持有意分工", () => {
const desktopDetail = source("src/pages/ReimbursementDetailPage.tsx");
const mobileDetail = source("src/pages/MobileReimbursementDetailPage.tsx");
const approvalAdapter = source("src/pages/reimbursement-workspace/ReimbursementApprovalAdapter.tsx");
expect(desktopDetail).toContain("<ProcessingProgress");
expect(mobileDetail).toContain("<ProcessingProgress");
expect(desktopDetail.match(/targetLinkLabel="查看所在付款批次"/g)).toHaveLength(1);
expect(mobileDetail.match(/targetLinkLabel="查看所在付款批次"/g)).toHaveLength(1);
expect(approvalAdapter).toContain('data-testid="reimbursement-workflow-history"');
expect(approvalAdapter).not.toContain("<ProcessingProgress");
});
test("票据申报事实与系统金额证据状态分属正文和处理上下文", () => {
const baseDetail = {
items: [{
id: "item-1",
expenseTypeId: "type-1",
expenseDate: "2026-09-01",
amount: "100.00",
description: "合成明细",
assetPriorApprovalBasis: null,
expenseType: { id: "type-1", code: "office_supplies", name: "办公用品", category: "office", enabled: true, requiresInvoice: true },
}],
attachments: [],
};
const buildDetail = (amountEvidenceStatus: "verified" | "manual_review") => ({
...baseDetail,
invoices: [{
id: "invoice-1",
status: "bound",
invoiceCode: "CODE-1",
invoiceNumber: "NUMBER-1",
invoiceDate: "2026-09-01",
sellerName: "合成供应商",
buyerName: null,
documentCategory: "invoice",
documentSubtype: "vat",
totalAmount: "100.00",
originalName: "invoice.pdf",
mimeType: "application/pdf",
fileSize: 100,
linkedItemId: "item-1",
amountEvidenceStatus,
}],
});
const snapshot = { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable" as const, parseStatus: "ready" as const };
const verified = buildReimbursementApprovalEvidence(buildDetail("verified"), snapshot).items[0].invoices[0];
const manualReview = buildReimbursementApprovalEvidence(buildDetail("manual_review"), snapshot).items[0].invoices[0];
const declaredFact = ({ amountEvidenceStatus: _context, ...fact }: typeof verified) => fact;
expect(declaredFact(verified)).toEqual(declaredFact(manualReview));
expect(verified.amountEvidenceStatus).toBe("verified");
expect(manualReview.amountEvidenceStatus).toBe("manual_review");
});
test("打印页保持组合归档视图且归档元数据不进入交互工作台", () => {
const printPage = source("src/pages/ReimbursementPrintPage.tsx");
const desktopShell = source("src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx");
const mobileShell = source("src/components/reimbursements/ReimbursementMobileWorkspaceShell.tsx");
expect(printPage).toContain("const totalAmount = detail.totalAmount");
expect(printPage).toContain("<QRCodeSVG");
expect(printPage).toContain("打印时间:");
expect(printPage).toContain("打印人:");
expect(printPage).toContain("审批记录");
for (const shell of [desktopShell, mobileShell]) {
expect(shell).not.toContain("QRCodeSVG");
expect(shell).not.toContain("打印时间:");
expect(shell).not.toContain("打印人:");
}
});
@@ -387,6 +387,14 @@ test.describe("R7E 审批动作二次确认", () => {
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
history: Array.from({ length: 6 }, (_, index) => ({
id: `history-${index + 1}`,
action: index === 0 ? "submit" : "approve",
actorName: index === 0 ? "申请人" : `审批人 ${index}`,
actorEmployeeNo: `E${index + 1}`,
comment: index === 5 ? "这是一条用于验证右栏长意见自然换行且不会把审批动作推到历史之后的合成长意见。".repeat(3) : `第 ${index + 1} 条处理意见`,
createdAt: pendingTaskCreatedAt,
})),
};
const reimbursementDetail = {
id: reimbursementId,
@@ -425,6 +433,22 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByRole("heading", { name: "报销单 BX-APPROVAL-001" })).toHaveCount(1);
await expect(page.getByText("客户拜访交通费", { exact: true })).toHaveCount(1);
await expect(page.getByTestId("reimbursement-approval-evidence")).toContainText("客户拜访打车");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("明细合计");
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥180.00");
await expect(page.getByText("报销总额与明细汇总不一致,请联系管理员")).toHaveCount(0);
await expect(page.getByText("明细金额无法完整核对,请联系管理员")).toHaveCount(0);
await expect(page.getByTestId("workflow-task-approve")).toBeEnabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeEnabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeEnabled();
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeEnabled();
await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6);
await expect(page.getByRole("heading", { name: "处理历史" })).toHaveCount(1);
const actionBox = await page.getByTestId("workflow-task-action-panel").boundingBox();
const historyBox = await page.getByTestId("reimbursement-workflow-history").boundingBox();
expect(actionBox).not.toBeNull();
expect(historyBox).not.toBeNull();
expect(actionBox!.y + actionBox!.height).toBeLessThanOrEqual(historyBox!.y);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true);
await expect(page.getByTestId("approval-unlinked-invoices")).toContainText("FP-002");
await expect(page.locator("#reimbursement-workspace-snapshot")).toContainText("该次提交未留存预算快照");
await expect(page.getByText("当前申报金额高于已读取的票面金额合计")).toBeVisible();
@@ -469,4 +493,146 @@ test.describe("R7E 审批动作二次确认", () => {
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
});
test("移动报销审批保留正文末尾历史且底部动作不遮挡", async ({ page }) => {
const reimbursementId = "reimbursement-mobile-history";
const taskDetail = {
...pendingTaskDetail,
workflowInstance: {
...pendingTaskDetail.workflowInstance,
entityType: "reimbursement",
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" },
},
reimbursement: {
id: reimbursementId,
formNumber: "BX-MOBILE-HISTORY",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "100.00",
description: "移动端历史布局",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
history: Array.from({ length: 6 }, (_, index) => ({
id: `mobile-history-${index + 1}`,
action: index === 0 ? "submit" : "approve",
actorName: index === 0 ? "申请人" : `审批人 ${index}`,
actorEmployeeNo: `M${index + 1}`,
comment: index === 5 ? "用于验证移动端长意见换行与底部安全区的合成长意见。".repeat(4) : `移动端第 ${index + 1} 条意见`,
createdAt: pendingTaskCreatedAt,
})),
};
const reimbursementDetail = {
id: reimbursementId,
formNumber: "BX-MOBILE-HISTORY",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "100.00",
description: "移动端历史布局",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
items: [{
id: "mobile-item",
expenseTypeId: "mobile-type",
expenseDate: "2026-09-01",
amount: "100.00",
description: "移动端合成明细",
assetPriorApprovalBasis: null,
expenseType: { id: "mobile-type", code: "travel_meal", name: "伙食补助费", category: "travel", enabled: true, requiresInvoice: false },
}],
invoices: [],
attachments: [],
};
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetail });
await page.setViewportSize({ width: 375, height: 667 });
await login(page, MANAGER);
await page.goto(`/mobile/approvals/${pendingTask.id}`);
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00");
await expect(page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item")).toHaveCount(6);
const assertMobileGeometry = async () => {
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 2)).toBe(true);
const lastHistory = page.getByTestId("reimbursement-workflow-history").locator(".detail-timeline-item").last();
await page.evaluate(() => {
const scroller = document.querySelector<HTMLElement>(".hyb-mobile-shell .overflow-y-auto");
scroller?.scrollTo(0, scroller.scrollHeight);
});
const historyBox = await lastHistory.boundingBox();
const stickyBox = await page.getByTestId("mobile-approvals-sticky-actions").boundingBox();
expect(historyBox).not.toBeNull();
expect(stickyBox).not.toBeNull();
expect(historyBox!.y + historyBox!.height).toBeLessThanOrEqual(stickyBox!.y);
};
await assertMobileGeometry();
await page.setViewportSize({ width: 667, height: 375 });
await assertMobileGeometry();
});
for (const scenario of [
{
name: "权威总额与明细汇总不一致",
itemAmount: "99.99",
expectedMessage: "报销总额与明细汇总不一致,请联系管理员",
},
{
name: "明细金额无法解析",
itemAmount: "invalid",
expectedMessage: "明细金额无法完整核对,请联系管理员",
},
]) {
test(`PC 报销${scenario.name}时四类审批入口 fail closed`, async ({ page }) => {
const reimbursementId = `reimbursement-integrity-${scenario.itemAmount === "invalid" ? "unavailable" : "mismatch"}`;
const taskDetail = {
...pendingTaskDetail,
workflowInstance: {
...pendingTaskDetail.workflowInstance,
entityType: "reimbursement",
contextData: { standardViolations: [], budgetCheck: null, budgetCheckStatus: "not_applicable", parseStatus: "ready" },
},
reimbursement: {
id: reimbursementId,
formNumber: "BX-INTEGRITY-001",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "100.00",
description: "合成完整性场景",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
submitter: { id: "owner", employeeNo: "E001", name: "申请人" },
},
leaveRequest: null,
};
const reimbursementDetail = {
id: reimbursementId,
formNumber: "BX-INTEGRITY-001",
status: "reviewing",
reimbursementDate: "2026-09-01",
totalAmount: "100.00",
description: "合成完整性场景",
owner: { id: "owner", employeeNo: "E001", name: "申请人" },
items: [{
id: "item-integrity",
expenseTypeId: "type-integrity",
expenseDate: "2026-09-01",
amount: scenario.itemAmount,
description: "完整性核对明细",
assetPriorApprovalBasis: null,
expenseType: { id: "type-integrity", code: "office_supplies", name: "办公用品", category: "office", enabled: true, requiresInvoice: false },
}],
invoices: [],
attachments: [],
};
await mockWorkflowConfirmApis(page, { taskDetail, reimbursementDetail });
await login(page, MANAGER);
await page.goto(`/workflow/tasks/${pendingTask.id}`);
await expect(page.getByTestId("reimbursement-amount-summary")).toContainText("¥100.00");
await expect(page.getByRole("alert").filter({ hasText: scenario.expectedMessage })).toBeVisible();
await expect(page.getByText("差额", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("workflow-task-approve")).toBeDisabled();
await expect(page.getByTestId("workflow-task-return-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-reject-toggle")).toBeDisabled();
await expect(page.getByTestId("workflow-task-transfer-menu-item")).toBeDisabled();
});
}
});
+1 -1
查看文件
@@ -70,7 +70,7 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
全系统长表单默认支持草稿和清晰提交。保存草稿不触发审批、付款、发布或外部同步;提交前必须做业务校验和摘要确认;离开未保存页面要提示;错误提示必须定位到字段、明细、发票、附件或材料区,并保留用户已输入内容。移动端表单只保留必要字段、材料上传和底部主动作,不承接复杂配置表单。
全系统详情页采用统一证据结构。状态与下一步先行,业务摘要其次,明细、发票、附件、合同文件、资产材料、回单等证据居中,审批/处理历史靠后,当前动作面板固定在 PC 右侧或移动端底部。高风险动作不得放在用户看不到证据的位置,确认时必须展示对象摘要和后果。
全系统详情页采用统一证据结构。状态与下一步先行,业务摘要其次,明细、发票、附件、合同文件、资产材料、回单等证据居中,审批/处理历史靠后,当前动作面板固定在 PC 右侧或移动端底部。普通详情与长流程的历史位于主列证据之后;短审批流程可把完整历史放在 PC 右栏动作之后,但主列不得重复,移动端仍位于正文之后。高风险动作不得放在用户看不到证据的位置,确认时必须展示对象摘要和后果。
全系统证据材料统一在详情页集中处理。列表页和台账页只显示发票、附件、合同文件、回单、资产材料等证据的完整性摘要、缺失提示和进入详情入口,不直接承接预览、下载、删除、批量打包或敏感材料处理;这些动作进入详情页、详情命令区、`更多` 或授权审计路径。