fix: 统一报销付款状态与进度(09-04-reimbursement-state-gap-finance-view)
这个提交包含在:
@@ -12,6 +12,15 @@ This spec keeps hyb-oa-specific React/Vite, shared-component, business-boundary
|
||||
and verification contracts. Do not copy broad aesthetic guidance from
|
||||
`frontend-design` into this file; use the skill directly for design judgment.
|
||||
|
||||
## Convention: Reimbursement Payment Handoff Consistency
|
||||
|
||||
- `resolveReimbursementPaymentHandoff({ status, deletedAt, progress })` consumes the server's `payment_batch_created`, `payment_batch_review` and `payment_and_receipt` stages. It is a display projection, not a second payment eligibility or write state machine.
|
||||
- Only an explicit current creation stage followed by pending review/payment can advertise creation, and the caller still requires `payment.batch.manage`. Missing stages, unknown status or contradictory facts never produce a guessed creation action. Cancelled and failed batches use the existing stage explanation, not automatic recreation.
|
||||
- Read-only detail states (including reviewing, approved and paid) override global focus caching locally (`staleTime: 0`, refetch on mount and visibility return). Do not enable whole-app focus refetch or overwrite draft/rejected editors for this fix. A query error must not keep a stale actionable payment panel.
|
||||
- PC uses `asideContext` for current progress after `actionPanel`, with `lg:static` on the action card. Payment detail and reimbursement approval share this order; comments remain readable without horizontal overflow. Mobile retains its own safe-area action contract.
|
||||
- Test absent batch, review, export, receipt, paid, failed/cancelled, missing progress and no batch-read permission. Test create/review/export with real isolated roles, then return from batch to reimbursement. Include visibility return and refresh; a payment-only test does not prove the source detail is correct.
|
||||
- Wrong: `approved => create batch`, or `!targetHref => no batch`. Correct: stage-derived next step and one server-authorized destination, including a conservative unknown state.
|
||||
|
||||
## Scenario: Actionable Validation And Selectable Payment Channels
|
||||
|
||||
### 1. Scope / Trigger
|
||||
@@ -590,8 +599,8 @@ reference, not a copy of Feishu or Ant Design branding.
|
||||
- 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.
|
||||
- Reimbursement workflow history answers approvers “who acted, when and with what comment”. Desktop uses the right aside: non-sticky decision panel, current processing progress, then full history; the main column receives no dormant timeline slot. Mobile keeps evidence, action explanation, current progress and history in one column with necessary safe-area actions.
|
||||
- `ProcessingProgress` remains separate from workflow history and may include future stages. An authorized batch link may be promoted into the action panel; pass that exact `suppressedTargetHref` to progress so it appears only once. Do not remove historical facts as duplicates or mistake an absent authorized link for an absent batch.
|
||||
- `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.
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 隔离备份恢复演练实施计划
|
||||
|
||||
## 2026-09-11 配对恢复准备(未执行)
|
||||
|
||||
用户曾回传一次 DB_RESTORE_PASS:66 条迁移集合匹配、12 类表已验、隔离资源已清理。这是管理员回执,不是本轮独立复验;不能继续写“数据库从未成功恢复”。附件与应用配对恢复尚未证明。
|
||||
|
||||
接续采用简单隔离流程,不再扩复杂演练器。先获准只读核验最新本地/ARM 自然批次、manifest/包大小摘要、来源版本、时效、磁盘和独立镜像制品。历史备份时点及旧磁盘数值不当成今日事实。
|
||||
|
||||
实际恢复需单独授权具体隔离目标与资源:同一 manifest 的 DB 和附件恢复到一次性 PostgreSQL 与空目录;源/目标身份隔离、禁生产数据库连接及通知/OCR/支付外联;核对迁移、关键表、附件引用存在性与适用大小摘要,必要应用只读。只回传脱敏匹配数和异常类别,不输出记录或文件路径。仅清理本轮创建资源,旧备份和回滚制品不删。
|
||||
|
||||
摘要匹配不等于实测恢复;旧镜像不撤销留痕删除或票据释放;若未来恢复生产,备份时点后数据丢失风险仍须另批。当前只准备清单,未 SSH、未复制或恢复真实备份。
|
||||
|
||||
## 执行前门禁
|
||||
|
||||
- [x] 确认任务已在用户审阅最终规划摘要后由 `task.py start` 进入 `in_progress`。
|
||||
|
||||
@@ -1,5 +1,40 @@
|
||||
# 报销状态空隙与财务处理视角:设计
|
||||
|
||||
## 2026-09-11 下一步与侧栏一致性方案(已批准本地实施)
|
||||
|
||||
本轮只读核对已发布候选,未查询生产。ReimbursementDetailPage 的 approved 分支无条件显示“等待财务组建付款批次”,其主动作只再检查 payment.batch.manage;同一页面 ProcessingProgress 已消费服务端按真实批次状态构造的阶段。修复必须连接这两处投影,不能只把错误文字换成另一句固定文案。
|
||||
|
||||
建议规则:
|
||||
|
||||
| 权威阶段 | 财务入口与说明 |
|
||||
| --- | --- |
|
||||
| 明确待组批 | 有组批资格时去组建付款批次;其他人等待财务处理 |
|
||||
| 批次待复核 | 有读取权限时进入既有批次;说明等待付款复核,不允许制单人自审 |
|
||||
| 已复核未付款 | 进入既有批次处理;说明消费真实制单/回单阶段,不在报销页复制下载/付款流程 |
|
||||
| 已付款 | 查看付款结果,不再提示组批或继续付款 |
|
||||
| 失败、取消、未知 | 按真实状态与既有资格解释;不能自动当成正常待组批或臆定可以重建 |
|
||||
|
||||
沿现有结构优先使用 ReimbursementDesktopWorkspaceShell 的 asideContext 放进度,不在 afterEvidence 再渲染一次;同时检查其审批工作台调用方。复用 BusinessDetailAside/ProcessingProgress,并处理操作卡单独 sticky 可能遮挡下方进度的问题。付款页保留现有正确结构,跨页使用同一布局约束而不是增加第二套付款状态机。
|
||||
|
||||
“统一”指信息层级、位置、动作与状态一致,不是让所有业务使用同一条审批链。当前处理进度与完整历史区分:前者固定在 PC 操作下方,历史内容不丢失、不伪装成当前待办。移动端沿既有壳与安全区动作,不扩复杂付款能力。
|
||||
|
||||
docs/产品设计规则.md 目前仍允许普通/长流程历史在主列,短流程在侧栏;待确认后应明确当前进度与完整历史的区别,统一已有页面和稳定规约。frontend-design 当前不可用,本阶段只复用已存在的付款侧栏样式;若实际需要新视觉方案,另行说明缺项,不擅自换设计体系。
|
||||
|
||||
修复后重点不是单页截图,而是“批次创建/复核/制单之后返回报销详情”的跨页面状态一致性,覆盖不同角色、无批次与无权读批次的区别、缓存重新聚焦、取消/失败及接口缺项。不承诺只改前端:若既有 DTO 不能可靠表达动作资格,先核实并在本阶段补最小合同,不改付款写规则、角色或 schema。
|
||||
|
||||
## 发布方案补充:候选 fe025e65 的回退语义
|
||||
|
||||
外部只读复核通过(部分覆盖),F2 非阻断但必须进入发布审批与交付说明。本节是候选之外的运维补充,不代表已演练回退。用户后续明确批准发布,实际授权、执行与自动核验结果以 implement.md 顶部最新回执为准;部署完成不消除本节回退限制。
|
||||
|
||||
- 生产当前版本、待执行迁移、磁盘余量、备份时点及完整性、独立旧镜像制品均需发布前重新读取;历史 86% / 8.93 GB 不是当前证据。
|
||||
- 本次包含 20260910010000_reimbursement_retained_delete 迁移。部署须明确 existing 模式,不执行初始化 seed;迁移在必需配置检查之前,检查失败不能宣称数据库零写入。
|
||||
- 应用问题的首选恢复方向为兼容现有数据的前向修复;如需退镜像,先核实新版本是否已产生留痕删除记录及其他新写入,禁止仅按镜像版本判断可安全回退。
|
||||
- 已发生留痕删除时,旧应用不理解 deleted_at,单据可能重新出现在列表;与此同时票据可能已经解绑或用于新单,审计已经记录删除。独立镜像制品只能还原代码,不能还原业务语义,不能声称“回滚即恢复原状”。
|
||||
- 有留痕删除记录时,回退方案须具备经审查的兼容过滤及历史只读约束,或另审受控数据库恢复方案。不清空删除标记、不删审计、不把已重用票据强制绑回旧单。
|
||||
- 数据库恢复需单独批准,明确备份时点后业务数据丢失与数据库/附件一致性风险;摘要匹配不等于恢复验证。既有隔离数据库恢复结果也不能自动推导本候选完整应用回退已通过。
|
||||
- 回退验收至少确认:正常列表/统计/付款候选不重新纳入已删除单据,历史与审计可读,票据不被重复占用或错误改绑。未验证时如实列缺口,不把“旧镜像可加载”作为验收完成。
|
||||
- 发布材料保留独立 reviewer 未覆盖的制单/前端/BIFF8 以及并发事务细节;既有自动测试是执行端证据,未通过完整发布门禁的例外须另批。部署后验证由执行端承担,任何生产测试写入不从只读授权推导。
|
||||
|
||||
## 2026-09-10 追加阶段:退回删除最小持久化方案(已批准本地实施)
|
||||
|
||||
用户在官方资料调研后确认实施:修改重提为主,删除为次;无需填写删除理由。易快报公开接口明确草稿/已驳回可删除,但未证明其物理存储或删除后发票释放方式;本项目留痕设计是自己的安全合同,不冒充行业统一实现。
|
||||
|
||||
@@ -1,5 +1,141 @@
|
||||
# 报销状态空隙与财务处理视角:实施计划
|
||||
|
||||
## 2026-09-11 本地联合验收回执(最新,未提交/未部署)
|
||||
|
||||
- 代码:付款下一步统一消费服务端进度;只有明确未组批才显示创建入口,已存在批次使用授权深链,无权/未知不猜。报销详情与报销审批工作台统一右栏操作→当前进度→适用的完整历史,手机保留单列和底栏动作;局部重入/聚焦刷新不覆盖编辑态。
|
||||
- 交接:第二阶段由 09-11-reimbursement-payment-handoffs 独立实现,再按补丁合入本副本;共享两份 QA 脚本保留双方断言。退回修改、待组批、待独立复核、付款/回单处理四类事项从实际业务状态只读投影,不依赖消息送达,不创建持久化 todo。
|
||||
- 验证:联合证据位于本副本 artifacts/qa-flow/20260911-combined/。unit.log 为 6 文件 135/135;ui.log 为 39/39、退出 0;build.log 含 typecheck/build/PWA 成功。real-db-browser.log 为 PASS_REAL_DB_HTTP、退出 0,真实一次性 PostgreSQL、密码登录/JWT、HTTP、PC/手机验证上传选票、分类编辑、退回重提、三步审批、组批、独立复核、中文 BIFF8、返回原报销的一致性及合成回单登记;四类门户事项随状态出现/消失、重读不写库。
|
||||
- 自动验证不使用真实员工。OCR 是真实 worker + mock provider;长历史是隔离库合成 fixture;回单与付款登记仅为隔离合成数据,没有银行连接。数据库容器与合成文件已清理。此次没有调用真实腾讯 OCR、查询生产、恢复正式备份或发送消息。
|
||||
- 质量:process/governance、错误解释 strict、UI strict 与 diff 检查通过;文档 registry errors=0、warnings=1(既有 22 项未注册文档)。主会话已只读检查全部本轮改动;独立 reviewer 未执行,不能抵扣完整候选门禁或公网验收。
|
||||
- 复盘:原动作仅由 approved 推断未组批,进度另算真实付款事实;原测试停在付款页,没有返回原单检查。结构修复使用同源阶段及单一授权入口,新增跨页返回、刷新、状态/角色反例;门户从只统计已有批次改为明确交接事项。先前 fixture/旧构建失败留在分项日志,不混计最终联合结果。
|
||||
- 首批启用仍未完成:员工生产就绪、最新备份/回滚点只读核验和配对恢复只完成准备;独立复核、候选提交推送、获批部署及公网受控验收均待执行。错误迁移剩余项、完整发布门禁、六个 view_all 未决口径与其他模块扩围不假报完成。
|
||||
|
||||
### 其他详情页初步盘点与归属
|
||||
|
||||
| 页面 | 本次源码事实 | 后续归属与边界 |
|
||||
| --- | --- | --- |
|
||||
| 报销详情、报销审批、付款详情 | 本轮已统一当前进度和动作位置,并回归返回原单 | 本 task + 09-08 付款 task,待发布 |
|
||||
| 非报销通用 WorkflowTaskDetailPage | 操作在右栏,正文是完整处理历史;不等同于已有独立当前进度 | 暂记本 task 扩围清单,具体业务启用时复用对应模块 task;不把正文历史机械搬走 |
|
||||
| PurchaseRequestsPage 详情 | 正文存在审批记录与归档操作,当前布局尚未按首批规则验收 | 暂记本 task 扩围清单,采购启用再核范围;旧采购布局归档 task 仅追溯,不自动重开 |
|
||||
|
||||
这是初步差异清单,不是全系统视觉验收;门户活动动态也不算详情当前进度。先完成首批报销/审批/付款,不为每个页面新增 task。
|
||||
|
||||
## 2026-09-11 综合后续规划(已批准本地实施,生产操作另批)
|
||||
|
||||
用户明确批准按本规划连续实施和针对性验证,不重复询问同范围方案。执行副本为既有 integration 工作树,基线 fe025e65,官方 current 已核对为本 task;保留原有发布回执与规划差异,不混入根 checkout 的治理改动。先完成第一阶段,再按阶段归属推进可执行的后续工作;提交、推送、生产查询/写入、恢复和部署不由本次泛化实施授权推导。inline 自检不冒充独立 review。
|
||||
|
||||
目标是员工能独立完成首批报销、审批和付款交接,不以 task 清零、按钮上线或登录页正常作为准入。最近核验的生产候选为 fe025e65;今天未重新连接生产,实施/发布前仍要核对实际基线。本节整合最新状态矛盾、布局统一及此前就绪规划,后续按具体阶段归属执行,不把所有技术债都捆成一次发布。
|
||||
|
||||
### 已完成基础,不重复开发
|
||||
|
||||
选票自动生成明细和集中分类、退回留痕删除、中文报销制单与下载原因优化、付款详情右栏进度、AP 读取收紧,以及 existing 不运行初始化 seed、迁移预检和数据库生命周期隔离已有发布记录。只对被新改动影响的部分做回归。一次历史最小数据库恢复成功不等于当前候选配对附件及应用恢复已通过;昨晚公网未登录检查不等于已登录生产业务全链验收。
|
||||
|
||||
### 有序执行及任务归属
|
||||
|
||||
| 顺序 | 工作与归属 | 结束判据 |
|
||||
| --- | --- | --- |
|
||||
| 第一阶段 | 修复报销付款状态矛盾与主线详情布局,复用本 task,付款页面回归关联 09-08-reimbursement-payment-closeout | 已有有效批次时不再引导组批;动作/下一步/进度消费一致事实;PC 操作在右上、当前进度在其下,正文不重复;手机顺序一致且保留底栏动作。报销详情、审批工作台、付款详情的回到原单场景与角色/状态矩阵通过 |
|
||||
| 并行准备 A | 真实员工业务就绪与职责范围只读盘点,复用 AP/付款既有任务 | 区分能登录、能提交、能审批/复核、能付款;按确认身份核对角色、经理、审批解析、主体及资料存在性,不重复修已回填字段,不读取银行明文。列清真实缺项和维护人,不把 smoke 账号算成真人。需调整授权或回填时另列批准清单 |
|
||||
| 并行准备 B | 恢复退路,复用 08-23 恢复任务与 08-28 异机备份事件任务 | 先只读核验执行时最新本地/ARM 批次、版本及自然调度结果;获批后在受控隔离目标验证配对 DB/附件恢复、引用与适用摘要及必要应用读取。禁止生产连接、真实通知/支付/OCR外联;只清本轮隔离资源,不增复杂 runner,不删除回滚制品 |
|
||||
| 第二阶段 | 报销到付款的最小待办交接;复用已有观察证据,到实施前确认一个独立跨模块 task,不按页面拆任务 | 四类首批事项:退回待修改、财务待组批、负责人待付款复核、复核后财务付款处理/登记。工作台数量与明细一致、深链有权、办完消失;无需聊天提醒切换账号,重复投影不生成重复待办。通知失败不丢任务,不把站内消息、IM 入队和真实送达混成一个 PASS |
|
||||
| 第三阶段 | 首批统一候选联合验收及发布,复用本 task 与付款任务的发布验收 | 各改动独立提交单元/工作树,先完成本地真实后端、合成角色、桌面/手机和跨页面闭环,再冻结统一候选交审。批准后部署并由执行端完成公网专用测试身份的必要受控合成验收,不用管理员代替各角色,不触碰真实单据或执行银行付款 |
|
||||
| 第四阶段 | 首批正式启用与其他详情页扩围 | 报销/审批/付款满足以下准入后即可开始正常业务,不等待合同/采购等全部功能或历史 task 清零。对其他已有详情页先形成实际差异清单,复用各模块任务按同一位置/信息层级规范整改;全系统统一不等于本轮重写全部页面或给所有业务套一条审批链 |
|
||||
|
||||
第一阶段先以失败用例锁定截图中的矛盾,不先花一轮全系统测试重复证明已知缺陷。就绪与恢复先做只读准备,生产资料写入、消息发送、实际恢复、部署和生产合成写入均按窄范围另批。真实付款是财务业务动作,不用一次真实银行付款来证明自动化测试完成。若某项权限或恢复风险阻断,按具体原因停相应启用阶段,不靠缩小验收或默默接受风险前进。
|
||||
|
||||
### 首批员工启用准入
|
||||
|
||||
1. 核心角色实际授权和必要主数据一致;确认 CEO 审批路由保留、财务不能自审,六个 view_all 的未决口径不默默改动,已确认不适当的数据暴露先处理。
|
||||
2. 上传/OCR、选票建明细、分类编辑、提交、退回修改重提/删除重建、审批、组批、独立复核、中文制单、合成回单登记均有适当层级的执行证据;旧页面刷新/重入不保留失效动作。真实腾讯接口验证与 mock OCR 分列。
|
||||
3. 创建/复核批次后回到报销详情,主按钮、下一步、进度及实际目标一致;未组批、各批次阶段、取消/失败、无权读取和状态未知均覆盖,不靠字符串存在性或截图替代状态断言。
|
||||
4. 四次任务交接可从系统发现和进入;如企业微信是约定入口,须有受控接收端送达与深链证据,无设备/授权时如实保留缺口,不让普通员工承担基础验收。
|
||||
5. 执行时备份完整/新鲜、配对数据与材料有隔离恢复证据,回滚制品仍保留;不把旧镜像存在当作新删除语义可逆。
|
||||
6. 新增候选的部署及公网检查通过,阻断缺陷清零;未覆盖范围逐项列明。首批稳定版本使用不要求全项目所有测试历史债归零,但定向业务验收不冒充完整候选门禁。
|
||||
|
||||
### 同期截止日与后排工作
|
||||
|
||||
- 错误解释复用 09-05-error-explainability-payment-readiness:当前候选 673 条豁免指纹仍于 2026-10-05 到期,按 09-11 为 24 天;已有分组与首批分支提案,不重做一份台账。现在确定首批范围,09-18 验证首批迁移,09-25 复审剩余量,09-30 前逐模块裁决延期,10-02 查漏;不统一延长日期,不把豁免数等同产品缺陷数。
|
||||
- 完整候选门禁复用 09-06-release-gate-integrity 与 09-07-e2e-auth-preconditions:先恢复可信认证前置和验证结果,再评审强制回执与启用。旧 58 条观测不逐条盲修,R1/合同阶段 B 不因本规划自动解冻;正常发布能力未恢复前,每次例外单独批准,不能继承昨晚例外。
|
||||
- F1 付款侧显式删除过滤仍归本 task 非阻断加固,排在已确认状态矛盾之后;是否随相关付款读取修订实施以范围审查为准,不默改冻结候选。
|
||||
- 随阶段收尾同步已有 task 的代码、发布、业务验收、等待事项与正确证据位置,尤其各隔离副本的后续回执;不批量归档、不重构大文件、不拆微服务、不为减少任务数合并不相关提交。
|
||||
|
||||
本规划已获批准;第一、二阶段本地实施与联合验证完成,两个并行清单已准备。下一步是冻结本地补丁交审,获准后分别提交推送并刷新限定只读前置;不是默认执行生产写入或全系统改造。产品规则与共享规约已同步本次实际实现,生产就绪与恢复结论保持待验证。
|
||||
|
||||
## 2026-09-11 当前新增缺陷与接续点(本地完成,发布待批准)
|
||||
|
||||
- [x] 核对用户反馈与 fe025e65 源码:已组批/已复核时,右侧 approved 固定组批提示与真实付款进度矛盾;主列进度与付款页右栏布局不一致。未连接生产、未读真实业务响应、未改运行代码。
|
||||
- [x] 将两项并回本 task 的 PRD/design,不新建修复 task;保留昨晚发布及既有自动验证原结论,但不把它们当作本次跨页面一致性已通过。
|
||||
- [x] 基于已有发布回执的 fe025e65 在隔离副本实施,先跑红已复核批次仍提示组批的用例;保留副本原有 task 回执,不混入根 checkout 的治理改动。
|
||||
- [x] 统一报销下一步、权限化目标入口和处理阶段;数据未知不猜未组批,取消/失败不放宽重建资格。
|
||||
- [x] 按既有付款详情样式调整报销/审批工作台侧栏,覆盖手机顺序、长历史、滚动和无重复入口;同步现有产品规则与共享规约。
|
||||
- [x] 列出已检查的其他详情页位置差异和后续归属;未声称全系统已统一。
|
||||
- [x] 角色/状态矩阵、真实隔离跨页面流程、双端布局和变更范围内回归通过,完成主会话只读检查。
|
||||
- [ ] 独立 reviewer 复核本次未提交补丁;不继承 fe025e65 旧候选审查结论。
|
||||
- [ ] 明确提交/发布候选后另批部署及所需生产合成验收,不让员工承担基础测试、不操作真实付款。
|
||||
|
||||
下一步是两笔任务改动的只读复核与提交准备;提交推送、生产核验和部署各按明确边界批准,不从此前 fe025e65 发布授权推导。不要求先清空其他历史任务。
|
||||
|
||||
## 2026-09-10 已授权发布回执(本阶段最新状态)
|
||||
|
||||
- 代码:73fcd5a6 与 fe025e65 两笔已推送,最终候选 fe025e65cfb521b3b8e758652f96448fefe05051;发布使用独立干净 clone 跟踪 origin/main,provenance 无 override。未移动原主 checkout 或提交其他未完成任务。
|
||||
- 发布:23:11:59(Asia/Shanghai)第二次正式 deploy-existing 退出 0,生产由 e5685f18 更新到 fe025e65;完整通过现有聚合体检、preflight、init、切换、post-preflight、smoke、备份合同 refresh。第一次仅构建依赖下载 ECONNRESET,失败和重试证据分别保留,不记成一次无故障发布。
|
||||
- 发布后自动只读核验:23:12 运行代码/Prisma 文件 10 项摘要匹配,68 条迁移全部完成且历史 checksum 一致,新删除列/约束/索引有效;无 pending。init 仅 validate_config → migrate → check_required_configuration,existing 无 seed;数据库容器与启动时间不变,远端环境文件摘要不变,腾讯 OCR 配置与 server 内任务执行保持开启,本轮未调用腾讯识别。
|
||||
- 公网:/ready 200 且版本匹配;HTML 和 12 份入口/相关模块 JS/CSS 与运行镜像逐项匹配;正式公网扫描 106 份资源、buildMarker PASS、findings=0。报销/付款/发票池匿名读取均 401;角色常量矩阵保持经理/CEO 无全量台账、财务/财务主管/管理员可读且审批复核能力保留。这不是生产真人账号的已登录业务动作测试。
|
||||
- 浏览器补查仅覆盖公网未登录入口:桌面 1440×1000、手机 390×844 登录控件可见且无横向溢出,两份截图已查看,console errors/warnings 均 0。登录页本身不显示 build,版本通过 /ready 与资源摘要证明,不以页面版本标签验收。关闭命令回报 closed,但外层命令退出 143,额外确认本次浏览器进程已退出;不记作整组浏览器命令退出 0,也不替代已有隔离业务测试。截图在主 checkout 的 output/playwright/20260910-release-fe025e65/;最终汇总为 release-summary.json。
|
||||
- 恢复与容量:e5685f18 独立镜像归档再次校验通过并保留,生产剩余 41.05 GiB,无需本轮清理。发布前最新备份完整性通过、时效 20.60 小时;备份合同已刷新到新版本,但不把旧备份说成新版本同源恢复点。本次未做数据库/附件/应用恢复演练,留痕删除后的兼容回退限制不变。
|
||||
- 证据:主 checkout 下 artifacts/qa-flow/20260910-release-fe025e65/ 的 deployment-attempt2-result.json、postcheck-result.json、runtime-postcheck.json 及受控 pre/post/构建日志。已核验的本地 296+7 单测、19 页面回归、真实隔离联合流程和用户提供的有限覆盖独立复核继续有效,不冒充本轮重跑或完整候选验收通过。
|
||||
- 业务与待办:未执行真实单据创建/删除/复核、制单导出或付款,不要求员工代测基础流程。F1 付款侧显式 deleted_at 纵深防御仍留在本 task 的非阻断后续项;此次追加阶段已发布不等于整个历史 task 可归档。任务补记未自动提交、未归档、未删除任何既有生产备份/回滚制品。
|
||||
|
||||
## 本轮发布授权与执行清单
|
||||
|
||||
用户明确回复“批准”,本轮批准部署已推送 fe025e65cfb521b3b8e758652f96448fefe05051,执行唯一新增迁移 20260910010000_reimbursement_retained_delete。使用既有 deploy-existing 正式路径、DEPLOY_DATA_MODE=existing、SYNC_ENV=0;不执行初始化 seed、不改真实报销/付款、不清理独立回滚制品。有限覆盖独立复核、未完成完整候选验收及留痕删除后的回退语义限制随本候选保留,不以过去例外替代本次批准。
|
||||
|
||||
- [x] 在干净且跟踪 origin/main 的独立发布 clone 核验已推送候选,刷新生产基线、备份完整性/时效、迁移和磁盘;有实质漂移则停止。正式 provenance 要求 upstream,detached worktree 不满足;不启用未发布 override,不移动原 checkout 的 main。
|
||||
- [x] 走正式聚合体检、迁移 preflight、构建/init/切换、post-preflight 和备份合同 refresh;保留受控日志。
|
||||
- [x] 自动只读核验公网版本/资源、运行时代码、迁移结构、existing 无 seed 和独立回滚归档仍在;不让真实用户代测、不执行真实业务动作。
|
||||
- [x] 在既有两项 task 补记实际发布与证据边界,不自动提交说明或归档。
|
||||
|
||||
发布执行中补记:第一轮正式聚合体检/来源/preflight 后,build-init 的 npm ci 出现 ECONNRESET,并记录非零构建错误;未进入 run-init。远端构建/发布进程已退出,但本地 SSH 传输未收尾;核对仅属于本轮的 SSH 子进程后终止该连接,发布器最终退出 1。失败构建原始日志已保存到受控 0600 本地文件,不回显网络/路径原文。23:06 再次只读核对生产仍 e5685f18、已应用 67/唯一 pending 不变、备份与 e568 独立镜像摘要通过、41.95 GiB 剩余;没有执行迁移、seed 或应用切换。第二次仍用同一候选与正式门禁,不改锁文件/镜像来源/依赖校验、不绕门禁,日志和 pre/post 证据单独命名保留。
|
||||
|
||||
## 当前运行镜像独立制品固定完成
|
||||
|
||||
用户单独批准保存并校验当前 e5685f1 镜像;不包含服务重启、容器切换、旧镜像清理或部署。本轮复用既有 save-image 操作脚本,仅固定当前 build/image 身份并增加项目身份核对,SSH stdin 执行,没有向生产上传脚本或读写业务库。
|
||||
|
||||
- 2026-09-10 22:36–22:37(Asia/Shanghai)完成:当前运行版本 e5685f18、镜像 0a3792699576…,保存到部署用户家目录下既有 rollback-artifacts 的唯一新子目录。完整相对定位、归档摘要、大小及模式见 artifacts/qa-flow/20260910-combined/rollback-current-receipt.json。
|
||||
- docker image save 仅导出镜像;归档 1,119,163,904 字节(约 1.04 GiB)。配置摘要与镜像 ID 匹配,14/14 层 diff_id 匹配,归档 SHA-256 为 b52c259cc84128761e452764e434d8fe3772d649147c124bb378ded4528d5fe8;文件 0600、目录 0700,保存脚本退出 0。
|
||||
- 新 SSH 连接回读落盘归档、receipt.json 和 image.tar.sha256,大小/摘要再次一致、当前运行版本与镜像仍匹配;rollback-current-readback.json 为 PASS_INDEPENDENT_FILE_READBACK,退出 0。这里“independent”仅指另一连接的文件回读,不代表独立代码 reviewer 或异机副本。
|
||||
- 保存前后运行容器及启动时间不变,未重启/切换;剩余磁盘约 42.45 GiB。只创建本次新归档目录、image.tar、摘要文件和回执;没有删除既有备份/制品/镜像或修改生产权限。
|
||||
- 制品独立于 Docker 存储,但仍在生产同一主机;没有 docker load、应用恢复、附件恢复或本次数据库恢复演练。镜像退回不撤销新版本业务写入,首次留痕删除后的回退语义限制保持不变。
|
||||
|
||||
当前制品固定前置已补齐;这不是发布批准。下一步将 fe025e65 的迁移、有限覆盖独立复核、完整候选验收缺口、回退方案及部署后自动检查范围交审,实际执行前刷新有时效的生产/备份证据。未部署、未归档、未提交本地说明补记。
|
||||
|
||||
## 推送及限定只读发布预检回执
|
||||
|
||||
用户明确批准推送两笔提交及只读发布预检,不包含部署或生产写入。已将 73fcd5a6、fe025e65 推至 origin/main,远端回读为 fe025e65;未移动有既有改动的主 checkout,未提交本地 F1/F2 文档补记。
|
||||
|
||||
2026-09-10 22:31–22:32(Asia/Shanghai)重新读取,证据为 artifacts/qa-flow/20260910-combined/production-precheck.json、backup-admin-check.json、precheck-summary.json:
|
||||
|
||||
- 生产容器仍运行 e5685f18,检查期间容器及启动时间未变化;备份服务版本合同匹配。未做新镜像构建/应用切换,不凭容器 running 宣称完整业务健康。
|
||||
- 只读事务核查 _prisma_migrations:已完成 67 条,候选 68 条,唯一待执行 20260910010000_reimbursement_retained_delete;未知/异常/历史校验和不匹配均 0。生产尚无两个删除标记列。未执行 migrate/seed,不额外读取业务表或个人历史。
|
||||
- 数据库、附件、部署用户回滚目录所在文件系统均约 43.50 GiB 可用、使用率 26.2%。旧 86% / 8.93 GB 不再作为当前依据;本次不清理任何生产文件、镜像或备份。
|
||||
- 普通部署账户备份读取被拒绝;通过已有配置的管理员 SSH 通道仅只读核验,未改权限/凭据/服务。最新备份北京时间 2026-09-10 02:30:47 完成,核验时 20.02 小时;manifest、数据库包、附件包大小/摘要通过,备份版本及镜像均匹配当前生产 e5685f18。结论 PASS_INTEGRITY_ONLY,本次未恢复演练。
|
||||
- 已登记独立回滚制品目录有两份其他版本回执,没有与当前 e5685f18/imageId 匹配的制品。不能用旧版本制品替代当前生产回滚点,也不把运行镜像存在当独立制品已固定;其他未登记位置不作不存在断言。
|
||||
- 身份核验使用既有 bootstrap 的只读 origin/database 模式;限定查询未运行会额外读取合同状态的完整 production-preflight 脚本,不宣称完整发布门禁通过。远端探针由 SSH stdin 执行,没有上传脚本/创建远端文件、生产数据写入或服务重启。
|
||||
|
||||
下一步需单独批准持久化保存当前生产 e5685f18 镜像为独立制品并校验(不重启/切容器/删旧镜像),再交审绑定 fe025e65 的发布方案与回退语义;保存制品不等于批准部署。备份接近 24 小时时限,实际发布前需重新核验时效。当前未部署、未归档。
|
||||
|
||||
## 已提交候选的外部只读复核结果
|
||||
|
||||
用户回传独立 reviewer 对 fe025e65(树 de050d89)的结论:通过,0 阻断、2 非阻断;manifest 8/8 摘要匹配。结论来源是用户提供的独立审查报告,不是主会话独立复现。两笔本地提交已完成:73fcd5a6(删除)、fe025e65(付款)。本节覆盖下文“未提交/待独立复核”的历史阶段状态。
|
||||
|
||||
- 覆盖:重点核查清单 1/3/6(删除授权、旁路排除、回退语义);清单 2 只看事务内锁顺序的相对位置,没有逐条验证同事务性与回滚。
|
||||
- 未覆盖:清单 4/5(制单/文件名/确认、页面/BIFF8);未跑测试、未审前端 diff、未验证迁移回退、未连生产。本地 296+7 单测、19 页面回归和隔离真实流程仍只是执行端自检,不能因摘要匹配改称 reviewer 重跑通过。
|
||||
- F1:付款读取未显式过滤 deleted_at,作为非阻断纵深防御待办留在本 task,不另开任务。本轮先不改运行时代码,保留已审 fe025e65;后续若实施需新提交/新候选、针对性付款状态与可见性回归,独立结论只覆盖原 SHA,不自动延伸。
|
||||
- F1 补充核实:迁移 CHECK 已要求有完整删除标记时 status='cancelled',付款 readiness 拒绝非 approved;当前不只依赖删除入口白名单。仅扩大可删除的原始状态并不必然绕过付款门禁,未来修改持久化状态或 CHECK 时仍须复核付款约束。不可不加判断把用户读取范围构造器套入付款数据加载。
|
||||
- F2:已补入 design 的发布方案:旧镜像只能还原代码,不能撤销删除标记/审计/票据释放;首次留痕删除后不得仅退镜像并宣称恢复原状。
|
||||
- 当前代码与候选均未改变;本次只有未提交的任务说明补记,不篡改已冻结 review.md/review-manifest.json。尚未推送、部署或归档;生产版本/迁移/磁盘/备份时点/回滚制品需获得只读授权后重新核验,不复用历史数值。
|
||||
- 下一步:取得推送及限定只读发布预检的明确授权;部署、新迁移执行及真实业务写入仍另批。独立复核的未覆盖部分须保留在发布审批材料中。
|
||||
|
||||
## 2026-09-10 退回删除接续
|
||||
|
||||
### 提交准备(未提交)
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
## Goal
|
||||
|
||||
### 2026-09-11 追加需求:付款下一步与详情侧栏一致性(已批准本地实施)
|
||||
|
||||
用户反馈财务在报销详情同时看到“去组建付款批次”和已创建、已复核的付款进度。只记录脱敏状态矛盾,不复制真实截图、票据或金额。已发布 fe025e65 源码中右侧下一步及主动作只按报销 status=approved 判断,忽略已由服务端提供的付款阶段;报销进度插在主列材料之后,付款详情则在右栏操作下方。报销审批通过并不等于尚未组批,也不等于银行已经付款。
|
||||
|
||||
- 操作、下一步和进度必须消费同一份权威付款阶段,不再各自仅由报销状态猜测。只有明确处于待组批且当前用户有组批权限时才展示组批入口;已存在有效批次时进入该批次,不重复组建。
|
||||
- 复用既有服务端 processingProgress、付款阶段语义和权限控制;批次入口不可因缺少 targetHref 而推断“未组批”。数据缺失、请求失败或不完整时不展示猜测性的创建入口。
|
||||
- PC 统一为主列业务内容/材料,右栏上方操作及下一步、下方当前处理进度;进度不在主列重复。长历史按既有授权完整保留,可折叠或进入历史区域,不能因侧栏变窄截掉意见/记录。手机保持单列及底栏动作,同类页面状态/下一步/进度顺序一致,不强做双栏。
|
||||
- 首批闭合报销详情、报销审批工作台与付款批次详情的状态/位置一致性;其他业务详情先列实际差异再按归属分批调整,不以“统一”重写全部页面、复制审批引擎或改变各业务状态机。
|
||||
- 按已批准体验复用现有侧栏/进度样式,不引入新视觉体系。全系统位置原则需在方案获批后同步既有产品规则及共享组件规约,避免现有规则仍允许两种位置。
|
||||
|
||||
本阶段验收:无批次、待复核、已复核未制单、已制单未付、有回单待登记、已付款、失败/取消及数据未知分别有准确下一步;角色正反例和批次深链权限正确,同一目标入口不重复。必须在真实隔离角色链中创建/复核批次后返回原报销详情,比较动作、说明与进度;另测已打开页面重新聚焦/刷新,不能只检查付款页成功或源码存在某句文案。桌面与手机覆盖长材料、长意见、滚动、侧栏遮挡/溢出及显示唯一性。生产合成写入、消息发送和部署仍另批,不拿真人单据回放。
|
||||
|
||||
### 2026-09-09 后续阶段:退回报销的删除与重建
|
||||
|
||||
本阶段归属复用,不重开原 R1/R2 的全部范围;综合顺序与验证见 09-08-reimbursement-payment-closeout 的最新 design/implement。用户已明确业务取向:退回后既能修改原单重提,也能删除旧单再创建报销提交;界面使用“删除”,不要求员工理解底层作废/留痕术语。2026-09-10 行业调研后用户确认本地实施;本追加阶段允许 design 中的 nullable 删除标记迁移,旧 R1/R2 的“无 schema 变更”约束不适用于本阶段。提交、推送及部署仍另批。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "reimbursement-state-gap-finance-view",
|
||||
"name": "reimbursement-state-gap-finance-view",
|
||||
"title": "报销状态空隙与财务处理视角",
|
||||
"description": "修复已通过报销在组批前缺少财务处理入口、草稿读取边界;追加退回报销留痕删除和发票重用。",
|
||||
"description": "修复报销状态空隙、草稿读取和退回留痕删除;追加付款下一步与详情侧栏一致性,保持原审批和付款写入边界。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "server,client,tests,spec",
|
||||
@@ -21,6 +21,6 @@
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "历史业务提交:R2=8e0af82,R1=5fe9f78。2026-09-10 退回留痕删除复用本任务,独立副本 20260910-rejected-delete;本地实现与验证完成,待独立复核及另批提交/部署,不继承旧阶段已发布结论。",
|
||||
"notes": "历史业务提交:R2=8e0af82,R1=5fe9f78;删除73fcd5a6随fe025e65于09-10发布。09-11付款下一步/侧栏修复与09-11待办投影按补丁联合,本地135单测、39页面和真实隔离流程通过。基线fe025e65不含本次未提交改动;独立复核、提交推送、生产就绪/恢复及发布验收待执行。详情见implement最新回执,不归档。",
|
||||
"meta": {}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Implementation Plan:错误可解释性与付款就绪治理
|
||||
|
||||
## 2026-09-11 到期治理接续(不统一续期)
|
||||
|
||||
本次只读复核现有 AST 基线为 673 条记录,到期仍为 2026-10-05,距 09-11 为 24 天;不是 673 个已证实产品故障。本轮主线修复不改到期日,不重复建立豁免台账。
|
||||
|
||||
先按员工首批业务路径排序既有延期域:付款账户维护(paymentAccounts)、发票池读取/识别/核验(invoicePool 系列)、登录/个人资料(auth/me)、审批动作错误(workflow)。这些是下一阶段候选模块名单,不批准整文件替换泛化错误或解冻配置/合同功能;实现时沿注册原因码、客户端映射、具体文本断言三件套按操作迁移。
|
||||
|
||||
09-18 验证首批操作迁移;09-25 按模块复审剩余记录;09-30 前逐模块写明未改原因、负责人与新验收点;10-02 查漏。只有有理由的单项/模块延期可交审,禁止到期前统一延长。当前仍全部保留原日期,本轮没有声称已完成余下错误迁移。
|
||||
|
||||
## 0. 开工门
|
||||
|
||||
- [x] 用户复核并批准 PRD、design、implement。
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# 执行顺序(本地技术审查/修复已批准,真实业务动作另批)
|
||||
|
||||
## 2026-09-11 业务就绪只读检查准备(未执行生产查询)
|
||||
|
||||
复用已确认身份和经理/主体回填回执,不再猜邮箱或索取工号。排除 smoke 账号,分别核对能登录、能提交、能审批/复核、能组批/登记,不能合并成一个笼统 ready。
|
||||
|
||||
- 登录:真实业务账号在职/启用及角色组合,只回传角色与布尔状态,不读取凭据或个人登录历史。
|
||||
- 提交:核对直接经理和适用审批人解析;财务经理已修正和 CEO 自报兜底只作为历史证据,不推断所有规则都能提交。
|
||||
- 审批/复核:保留 CEO 审批路由及非制单复核;不用 admin 通配代替业务角色,不修改六个 view_all。
|
||||
- 付款:主体、付款账户、收款银行资料只回传是否存在/启用/关联匹配。受控解密可用性需另列范围,仅回传成功/失败;门户待办不解密、不输出银行信息。
|
||||
- 有缺项只报告脱敏对象、阶段、维护职责;真实回填另批,不由本地测试自动推导生产 UPDATE。
|
||||
|
||||
报销详情一致性与四类业务待办的本地实施分别归 09-04 和 09-11 任务,联合验证已通过:135 单测、39 页面及真实隔离 HTTP/双端流程。证据见联合副本 artifacts/qa-flow/20260911-combined/;只登记合成回单,没有银行连接。本轮未查询生产、未调用真实腾讯 OCR、未实际付款或发消息;独立复核、提交和部署未完成。
|
||||
|
||||
## 2026-09-10 本轮付款体验已发布
|
||||
|
||||
- 代码:付款提交 fe025e65 与删除提交 73fcd5a6 已分别提交并推送,最终统一候选 fe025e65;中文“报销制单”文件名、下载免手填理由但保留敏感/重复下载确认、操作卡下方处理进度及 filename* 解析修复随本轮上线。本节覆盖下文历史阶段的“未提交/未发布”状态。
|
||||
- 发布:用户明确批准后,23:11:59(Asia/Shanghai)正式 deploy-existing 第二次执行退出 0。第一次依赖下载 ECONNRESET,确认未执行迁移/切换后按原候选重试,无更改锁文件或跳过门禁。新迁移仅来自删除提交;existing 无 seed,数据库容器不重启、环境文件不变、备份合同 refresh 成功。
|
||||
- 验证:本轮生产只读核验版本、代码/结构摘要、68 条迁移、HTML 与 12 份相关资源、匿名权限守卫和回滚制品;正式公网资源扫描 106 份、buildMarker PASS、findings=0。证据位于主 checkout 的 artifacts/qa-flow/20260910-release-fe025e65/,发布细节和回退限制共用删除 task 最新回执,不另建 task。
|
||||
- 公网浏览器补查桌面/手机未登录入口,控件可见、无横向溢出、console errors/warnings 为 0;不是已登录付款页面验收。关闭回报 closed 但外层退出 143,已另核本次浏览器进程退出,不包装为整组浏览器命令全绿。
|
||||
- 业务边界:本次不登录真人账号操作真实批次,不重复导出真实制单、不复核或付款;真实 BIFF8/中文下载/桌面手机付款流程的执行证据仍为合并候选已有真实隔离联合测试,不冒充生产真实付款验收。独立 reviewer 未审制单/前端/BIFF8、完整候选验收缺口保留。
|
||||
- 下一步:实际付款与回单继续按业务办理,不要求财务或经理替执行端发现基础缺陷。F1 非阻断加固留原删除 task 后续处理;不自动归档、提交补记或清理回滚制品。
|
||||
|
||||
## 2026-09-10 当前执行
|
||||
|
||||
### 合并候选验证与发布边界
|
||||
|
||||
@@ -10,6 +10,7 @@ type Props = {
|
||||
testId: string;
|
||||
mobile?: boolean;
|
||||
targetLinkLabel?: string;
|
||||
suppressedTargetHref?: string | null;
|
||||
};
|
||||
|
||||
const STATE_TEXT = {
|
||||
@@ -26,6 +27,7 @@ export function ProcessingProgress({
|
||||
testId,
|
||||
mobile = false,
|
||||
targetLinkLabel = "查看详情",
|
||||
suppressedTargetHref,
|
||||
}: Props) {
|
||||
return (
|
||||
<ol className="space-y-0" data-testid={testId} aria-label={ariaLabel}>
|
||||
@@ -61,7 +63,7 @@ export function ProcessingProgress({
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className={cn("min-w-0 flex-1 pb-5", mobile ? "pt-1" : "pt-1.5")}>
|
||||
<div className={cn("min-w-0 flex-1 break-words pb-5", mobile ? "pt-1" : "pt-1.5")}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-semibold text-zinc-900">{stage.label}</span>
|
||||
{facts.length ? <span className="text-zinc-500">· {facts.join(" · ")}</span> : null}
|
||||
@@ -73,7 +75,7 @@ export function ProcessingProgress({
|
||||
{stage.comment}
|
||||
</blockquote>
|
||||
) : null}
|
||||
{stage.targetHref ? (
|
||||
{stage.targetHref && stage.targetHref !== suppressedTargetHref ? (
|
||||
<Link
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ProcessingProgress } from "../types/processingProgress";
|
||||
|
||||
export function resolveReimbursementPaymentHandoff({
|
||||
status,
|
||||
deletedAt,
|
||||
progress,
|
||||
}: {
|
||||
status: string;
|
||||
deletedAt?: string | null;
|
||||
progress?: ProcessingProgress | null;
|
||||
}) {
|
||||
const unavailable = { nextStep: "付款进度暂不可用,请刷新后重试", canCreateBatch: false, targetHref: null as string | null };
|
||||
if (deletedAt || !["approved", "paid"].includes(status)) return null;
|
||||
const stages = progress?.stages ?? [];
|
||||
const created = stages.find((stage) => stage.key === "payment_batch_created");
|
||||
const review = stages.find((stage) => stage.key === "payment_batch_review");
|
||||
const payment = stages.find((stage) => stage.key === "payment_and_receipt");
|
||||
if (!created || !review || !payment) return unavailable;
|
||||
if (status === "paid") {
|
||||
return {
|
||||
...unavailable,
|
||||
nextStep: payment.state === "completed" ? payment.detail ?? "查看付款结果" : unavailable.nextStep,
|
||||
targetHref: payment.state === "completed" ? payment.targetHref : null,
|
||||
};
|
||||
}
|
||||
if (created.state === "current" && review.state === "pending" && payment.state === "pending"
|
||||
&& !created.targetHref && !review.targetHref && !payment.targetHref) {
|
||||
return { ...unavailable, nextStep: created.detail ?? "待财务组建付款批次", canCreateBatch: true };
|
||||
}
|
||||
if (created.state !== "completed") return unavailable;
|
||||
const current = [payment, review].find((stage) => stage.state === "current" || stage.state === "exception");
|
||||
if (!current || !current.detail) return unavailable;
|
||||
return { ...unavailable, nextStep: current.detail, targetHref: current.targetHref };
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { ReimbursementHeaderEditor } from "../components/reimbursements/Reimburs
|
||||
import { isTravelReimbursement } from "../lib/reimbursementTravel";
|
||||
import { ReimbursementMobileWorkspaceShell } from "../components/reimbursements/ReimbursementMobileWorkspaceShell";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { resolveReimbursementPaymentHandoff } from "../lib/reimbursementPaymentHandoff";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { MobileStickyActionBar } from "../components/patterns/MobileStickyActionBar";
|
||||
import { Button } from "../components/ui/button";
|
||||
@@ -191,6 +192,9 @@ export function MobileReimbursementDetailPage() {
|
||||
enabled: Boolean(reimbursementId),
|
||||
queryKey: ["reimbursements", reimbursementId],
|
||||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: (query) => Boolean(query.state.data && !["draft", "rejected"].includes(query.state.data.status)),
|
||||
});
|
||||
|
||||
const expenseTypesQuery = useQuery({
|
||||
@@ -907,6 +911,7 @@ export function MobileReimbursementDetailPage() {
|
||||
}
|
||||
|
||||
const showSubmitAction = Boolean(canMaintainReimbursement && canSubmitExpense);
|
||||
const paymentHandoff = resolveReimbursementPaymentHandoff({ ...detail, progress: detail.processingProgress });
|
||||
|
||||
const workspaceEvidence = buildReimbursementApprovalEvidence(detail, {
|
||||
standardViolations: (reimbursementPrecheckQuery.data?.standardChecks ?? []).filter((item) => item.exceeded),
|
||||
@@ -978,7 +983,8 @@ export function MobileReimbursementDetailPage() {
|
||||
const workspaceAfterEvidence = (
|
||||
<>
|
||||
{(detail.budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <section className="mobile-card space-y-3"><h2 className="text-base font-semibold text-zinc-900">需要关注</h2>{detail.budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {detail.budgetHint.budgetCode}:提交后预计使用 {detail.budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</section> : null}
|
||||
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" targetLinkLabel="查看所在付款批次" mobile /></section>
|
||||
{paymentHandoff ? <section className="mobile-card space-y-3" data-testid="mobile-reimbursement-payment-actions"><p className="text-sm">下一步:{paymentHandoff.nextStep}</p>{paymentHandoff.targetHref ? <Button asChild className="w-full"><Link to={paymentHandoff.targetHref}>查看所在付款批次</Link></Button> : null}</section> : null}
|
||||
<section className="mobile-card"><h2 className="mb-3 text-base font-semibold text-zinc-900">处理进度</h2><ProcessingProgress progress={detail.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="mobile-reimbursement-processing-progress" suppressedTargetHref={paymentHandoff?.targetHref} targetLinkLabel="查看所在付款批次" mobile /></section>
|
||||
{canDeleteDraft ? <section className="mobile-card"><details><summary className="cursor-pointer py-3 text-sm" data-testid="mobile-reimbursement-more">更多</summary><Button className="w-full" disabled={deleteReimbursementMutation.isPending} onClick={() => setConfirmDeleteReimbursement(true)} type="button" variant="destructive">删除报销单</Button></details></section> : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -48,6 +48,7 @@ import { ReimbursementTravelSummary } from "../components/reimbursements/Reimbur
|
||||
import { ReimbursementHeaderEditor } from "../components/reimbursements/ReimbursementHeaderEditor";
|
||||
import { ReimbursementDesktopWorkspaceShell } from "../components/reimbursements/ReimbursementDesktopWorkspaceShell";
|
||||
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
|
||||
import { resolveReimbursementPaymentHandoff } from "../lib/reimbursementPaymentHandoff";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
@@ -150,6 +151,9 @@ export function ReimbursementDetailPage() {
|
||||
enabled: Boolean(reimbursementId),
|
||||
queryKey: ["reimbursements", reimbursementId],
|
||||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: (query) => Boolean(query.state.data && !["draft", "rejected"].includes(query.state.data.status)),
|
||||
});
|
||||
|
||||
const expenseTypesQuery = useQuery({
|
||||
@@ -936,6 +940,7 @@ export function ReimbursementDetailPage() {
|
||||
const invoiceCount = detail.invoices.length;
|
||||
const hasBlockingPrecheck = blockStandardChecks.length > 0;
|
||||
const processingProgress = detail.processingProgress ?? { stages: [] };
|
||||
const paymentHandoff = resolveReimbursementPaymentHandoff({ ...detail, progress: detail.processingProgress });
|
||||
const currentProcessingStage = processingProgress.stages.find((stage) =>
|
||||
stage.state === "current" || stage.state === "exception",
|
||||
);
|
||||
@@ -959,12 +964,7 @@ export function ReimbursementDetailPage() {
|
||||
if (detail.status === "reviewing") {
|
||||
return currentProcessingStage?.detail ?? "查看处理进度";
|
||||
}
|
||||
if (detail.status === "approved") {
|
||||
return "等待财务组建付款批次";
|
||||
}
|
||||
if (detail.status === "paid") {
|
||||
return "查看付款结果";
|
||||
}
|
||||
if (paymentHandoff) return paymentHandoff.nextStep;
|
||||
return "查看记录";
|
||||
})();
|
||||
const primaryAction = (() => {
|
||||
@@ -1005,7 +1005,11 @@ export function ReimbursementDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (detail.status === "approved" && canManagePaymentBatch) {
|
||||
if (paymentHandoff?.targetHref) {
|
||||
return <Button asChild><Link to={paymentHandoff.targetHref}>查看所在付款批次</Link></Button>;
|
||||
}
|
||||
|
||||
if (paymentHandoff?.canCreateBatch && canManagePaymentBatch) {
|
||||
return (
|
||||
<Button asChild data-testid="reimbursement-open-pending-payment-batches">
|
||||
<Link to={`/payment-batches?view=pending&q=${encodeURIComponent(resolveReimbursementDisplayCode(detail))}`}>
|
||||
@@ -1105,11 +1109,10 @@ export function ReimbursementDetailPage() {
|
||||
const workspaceAfterEvidence = (
|
||||
<>
|
||||
{(budgetHint?.exceedsThreshold || detail.riskFlags.length) ? <SectionPanel title="需要关注">{budgetHint?.exceedsThreshold ? <p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%,已达到预警阈值。</p> : null}{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}</SectionPanel> : null}
|
||||
<SectionPanel title="处理进度"><ProcessingProgress progress={processingProgress} ariaLabel="报销处理进度" testId="reimbursement-workflow-progress" targetLinkLabel="查看所在付款批次" /></SectionPanel>
|
||||
</>
|
||||
);
|
||||
const workspaceActionPanel = (
|
||||
<BusinessDetailActionPanel status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{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> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
|
||||
<BusinessDetailActionPanel className="lg:static" status={<StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />} primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ? <div className="grid gap-2" data-testid="reimbursement-secondary-detail">{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> : null} summary={<><div>下一步:{nextStepText}</div>{workspaceModel.issueAnchors.map((issue) => <a className="block hover:underline" href={"#" + issue.id} key={issue.label}>{issue.label}</a>)}</>} testId="reimbursement-action-panel">
|
||||
<ReimbursementActions status={detail.status} showSubmitAction={false} showWithdrawAction={isReimbursementParticipant} 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>
|
||||
);
|
||||
@@ -1117,7 +1120,7 @@ export function ReimbursementDetailPage() {
|
||||
return (
|
||||
<>
|
||||
{downloadReasonDialog}
|
||||
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel }} />
|
||||
<ReimbursementDesktopWorkspaceShell model={workspaceModel} capabilities={workspaceCapabilities} slots={{ status: <StatusBadge status={detail.deletedAt ? "deleted" : detail.status} />, backAction: <Button asChild className="min-h-11 px-2" data-testid="reimbursement-back" variant="ghost"><Link replace to={backSource}><ArrowLeft aria-hidden="true" className="size-4" />{backLabel}</Link></Button>, headerDetails: workspaceHeaderDetails, beforeEvidence: workspaceBeforeEvidence, afterEvidence: workspaceAfterEvidence, actionPanel: workspaceActionPanel, asideContext: <SectionPanel title="处理进度"><ProcessingProgress progress={processingProgress} ariaLabel="报销处理进度" testId="reimbursement-workflow-progress" targetLinkLabel="查看所在付款批次" suppressedTargetHref={paymentHandoff?.targetHref} /></SectionPanel> }} />
|
||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[85vh] max-w-3xl overflow-y-auto"
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ReimbursementReturnReferencePicker } from "../../components/reimburseme
|
||||
import { BusinessDetailActionPanel } from "../../components/patterns/BusinessDetailTemplate";
|
||||
import { MobileStickyActionBar } from "../../components/patterns/MobileStickyActionBar";
|
||||
import { SectionPanel } from "../../components/patterns/SectionPanel";
|
||||
import { ProcessingProgress } from "../../components/patterns/ProcessingProgress";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
@@ -96,6 +97,9 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
enabled: Boolean(reimbursementId) && online,
|
||||
queryKey: ["reimbursements", reimbursementId],
|
||||
queryFn: () => fetchReimbursementDetail(reimbursementId),
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: detail.task.status !== "pending",
|
||||
});
|
||||
const snapshot = detail.workflowInstance?.contextData ?? null;
|
||||
const evidence = useMemo(() => {
|
||||
@@ -314,6 +318,11 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
const processingProgress = (
|
||||
<SectionPanel title="处理进度">
|
||||
<ProcessingProgress progress={reimbursementDetailQuery.data?.processingProgress ?? { stages: [] }} ariaLabel="报销处理进度" testId="reimbursement-approval-processing-progress" targetLinkLabel="查看所在付款批次" mobile={layout === "mobile"} />
|
||||
</SectionPanel>
|
||||
);
|
||||
const hasMaterials = model.evidence.items.some((item) => item.invoices.length > 0)
|
||||
|| model.evidence.unlinkedInvoices.length > 0
|
||||
|| model.evidence.attachments.length > 0;
|
||||
@@ -351,19 +360,19 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
);
|
||||
|
||||
if (layout === "mobile") {
|
||||
const mobileActions = detail.task.status === "pending" ? (
|
||||
<>
|
||||
const mobileDecisionContext = detail.task.status === "pending" ? (
|
||||
<section className="mobile-card space-y-3">
|
||||
{offlineNotice}
|
||||
{issueAnchors}
|
||||
{commentEditor}
|
||||
{canTransfer || !online ? <Button className="min-h-11 w-full" data-testid="mobile-approvals-transfer" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "transfer" ? null : "transfer")} type="button" variant="ghost">转审</Button> : null}
|
||||
</section>
|
||||
) : null;
|
||||
const mobileActions = detail.task.status === "pending" ? (
|
||||
<MobileStickyActionBar contentClassName="grid-cols-2" testId="mobile-approvals-sticky-actions">
|
||||
{allowedActions.has("return") || !online ? <Button className="min-h-12 w-full" data-testid="mobile-approvals-return" disabled={actionDisabled} onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")} type="button" variant="secondary">退回修改</Button> : null}
|
||||
<Button className="min-h-12 w-full" data-testid="mobile-approvals-approve" disabled={actionDisabled || !allowedActions.has("approve")} onClick={() => requestAction("approve")} type="button">通过</Button>
|
||||
</MobileStickyActionBar>
|
||||
</>
|
||||
) : null;
|
||||
return (
|
||||
<>
|
||||
@@ -375,7 +384,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
status: <StatusBadge label={workflowTaskStatusLabel(detail.task.status)} status={detail.task.status} />,
|
||||
beforeEvidence: detail.task.status !== "pending" ? <p className="mobile-card text-sm text-blue-800">当前显示报销单最新版本;审批意见与提交检查保留历史语义。</p> : null,
|
||||
afterEvidence: snapshot?.budgetCheckStatus === "missing" ? <p className="mobile-card text-sm text-zinc-500" id="reimbursement-workspace-snapshot">该次提交未留存预算快照。</p> : null,
|
||||
timeline,
|
||||
timeline: <>{mobileDecisionContext}{processingProgress}{timeline}</>,
|
||||
mobileActions,
|
||||
}}
|
||||
/>
|
||||
@@ -386,6 +395,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
|
||||
const actionPanel = (
|
||||
<BusinessDetailActionPanel
|
||||
className="lg:static"
|
||||
title="审批决定"
|
||||
beforeActions={actionPanelBeforeActions}
|
||||
primaryAction={allowedActions.has("approve") || allowedActions.has("return") ? (
|
||||
@@ -413,7 +423,7 @@ export function ReimbursementApprovalAdapter({ detail, layout, refetchTask, task
|
||||
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,
|
||||
actionPanel,
|
||||
asideContext: timeline,
|
||||
asideContext: <>{processingProgress}{timeline}</>,
|
||||
}}
|
||||
/>
|
||||
{dialogs}
|
||||
|
||||
@@ -126,8 +126,9 @@ test("PC 审批历史只进入右栏且移动历史保持正文末尾", () => {
|
||||
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);
|
||||
expect(approvalAdapter).toContain("asideContext: <>{processingProgress}{timeline}</>");
|
||||
expect(approvalAdapter).toContain("timeline: <>{mobileDecisionContext}{processingProgress}{timeline}</>");
|
||||
expect(approvalAdapter).toContain('className="lg:static"');
|
||||
});
|
||||
|
||||
test("申请人进度与审批历史保持有意分工", () => {
|
||||
@@ -140,7 +141,7 @@ test("申请人进度与审批历史保持有意分工", () => {
|
||||
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");
|
||||
expect(approvalAdapter.match(/<ProcessingProgress /g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("待组批工作台保持单一候选源且报销详情使用可刷新深链", () => {
|
||||
@@ -154,7 +155,7 @@ test("待组批工作台保持单一候选源且报销详情使用可刷新深
|
||||
expect(paymentPage).toContain("candidatesQuery.data?.total");
|
||||
expect(reimbursementDetail).toContain('hasPermission(permissions, "payment.batch.manage")');
|
||||
expect(reimbursementDetail).toContain("/payment-batches?view=pending&q=");
|
||||
expect(reimbursementDetail).toContain("等待财务组建付款批次");
|
||||
expect(reimbursementDetail).toContain("paymentHandoff?.canCreateBatch && canManagePaymentBatch");
|
||||
});
|
||||
|
||||
test("票据申报事实与系统金额证据状态分属正文和处理上下文", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRequire } from "node:module";
|
||||
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { login } from "./helpers/auth";
|
||||
import type { ProcessingProgress } from "../../src/types/processingProgress";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
const loadServerModule = createRequire(import.meta.url);
|
||||
@@ -103,7 +104,7 @@ function reimbursementListItem() {
|
||||
};
|
||||
}
|
||||
|
||||
function reimbursementDetail(status: "draft" | "reviewing" | "paid" = "draft", isTravel = false) {
|
||||
function reimbursementDetail(status: "draft" | "reviewing" | "approved" | "paid" = "draft", isTravel = false) {
|
||||
const workflowTaskId = "workflow-task-action-confirm";
|
||||
return {
|
||||
...reimbursementListItem(),
|
||||
@@ -309,7 +310,8 @@ async function mockSystemActionApis(
|
||||
paymentOrderFile?: Buffer;
|
||||
paymentOrderError?: boolean;
|
||||
paymentEntityType?: "reimbursement" | "travel_order";
|
||||
reimbursementStatus?: "draft" | "reviewing" | "paid";
|
||||
reimbursementStatus?: "draft" | "reviewing" | "approved" | "paid";
|
||||
reimbursementProgress?: () => ProcessingProgress;
|
||||
travelReimbursement?: boolean;
|
||||
meOverride?: Partial<typeof me>;
|
||||
} = {},
|
||||
@@ -419,7 +421,10 @@ async function mockSystemActionApis(
|
||||
}
|
||||
|
||||
if (path === `/api/reimbursements/${reimbursementId}` && request.method() === "GET") {
|
||||
await fulfillJson(route, reimbursementDetail(currentReimbursementStatus, options.travelReimbursement));
|
||||
await fulfillJson(route, {
|
||||
...reimbursementDetail(currentReimbursementStatus, options.travelReimbursement),
|
||||
...(options.reimbursementProgress ? { processingProgress: options.reimbursementProgress() } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -646,6 +651,100 @@ async function mockSystemActionApis(
|
||||
return calls;
|
||||
}
|
||||
|
||||
test.describe("报销付款交接一致性", () => {
|
||||
test.skip(process.env.E2E_MODE === "live", "仅合成数据,不访问公网");
|
||||
|
||||
function progress(status: SyntheticPaymentBatchStatus | "none" | "unknown", canRead = true, exported = 1): ProcessingProgress {
|
||||
if (status === "unknown") return { stages: [] };
|
||||
const { buildPaymentProcessingSemantics } = loadServerModule("../../../server/src/services/paymentProcessingProgress");
|
||||
const semantics = buildPaymentProcessingSemantics({ status, requiresBankReceipt: true, paymentOrderExportCount: exported });
|
||||
const stages = Object.values(semantics) as ProcessingProgress["stages"];
|
||||
for (const stage of stages) Object.assign(stage, { actorName: null, targetHref: null });
|
||||
if (status === "none") {
|
||||
Object.assign(stages[0], { state: "current", result: "待组批", detail: "待财务组建付款批次" });
|
||||
} else if (canRead) {
|
||||
stages[status === "draft" ? 1 : 2].targetHref = `/payment-batches?id=${paymentBatchId}`;
|
||||
}
|
||||
return { stages };
|
||||
}
|
||||
|
||||
for (const mobile of [false, true]) {
|
||||
test(`${mobile ? "手机" : "桌面"}已复核制单后返回报销不再组批且进度跟随操作`, async ({ page }) => {
|
||||
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 });
|
||||
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("reviewed") });
|
||||
await login(page, user);
|
||||
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
|
||||
const actions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel");
|
||||
await expect(actions).toContainText("已生成银行制单,等待付款完成并上传回单");
|
||||
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0);
|
||||
const link = page.getByRole("link", { name: "查看所在付款批次", exact: true });
|
||||
await expect(link).toHaveCount(1);
|
||||
await link.click();
|
||||
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(actions).toContainText("已生成银行制单");
|
||||
const timeline = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress");
|
||||
await expect(timeline).toHaveCount(1);
|
||||
const panel = actions.locator("..");
|
||||
expect(await panel.evaluate((element) => getComputedStyle(element).position)).not.toBe("sticky");
|
||||
const actionBounds = await actions.boundingBox();
|
||||
const progressBounds = await timeline.boundingBox();
|
||||
expect(progressBounds!.y).toBeGreaterThan(actionBounds!.y + actionBounds!.height);
|
||||
if (!mobile) expect(Math.abs(progressBounds!.x - actionBounds!.x)).toBeLessThan(35);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [status, expected] of [
|
||||
["none", "待财务组建付款批次"], ["draft", "待非制单人复核付款"],
|
||||
["reviewed", "已生成银行制单"], ["failed", "付款登记失败"],
|
||||
["cancelled", "付款安排已取消"], ["unknown", "付款进度暂不可用"],
|
||||
] as const) {
|
||||
test(`${status}阶段不因无批次读取权限而误判待组批`, async ({ page }) => {
|
||||
await mockSystemActionApis(page, {
|
||||
reimbursementStatus: "approved", reimbursementProgress: () => progress(status, false),
|
||||
meOverride: { roleCodes: ["employee"], permissions: ["expense.view_own"] },
|
||||
});
|
||||
await login(page, user);
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByTestId("reimbursement-action-panel")).toContainText(expected);
|
||||
await expect(page.getByRole("link", { name: /付款批次/ })).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test("重新聚焦后不保留旧的组批动作", async ({ page }) => {
|
||||
let current: SyntheticPaymentBatchStatus | "none" = "none";
|
||||
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress(current) });
|
||||
await login(page, user);
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toBeVisible();
|
||||
current = "reviewed";
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
||||
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
document.dispatchEvent(new Event("visibilitychange", { bubbles: true }));
|
||||
});
|
||||
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("已生成银行制单");
|
||||
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
|
||||
await page.reload();
|
||||
await expect(page.getByRole("link", { name: "查看所在付款批次", exact: true })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("已付款只保留结果入口,进度未知不让财务猜测组批", async ({ page }) => {
|
||||
await mockSystemActionApis(page, { reimbursementStatus: "paid", reimbursementProgress: () => progress("executed") });
|
||||
await login(page, user);
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("付款已登记完成");
|
||||
await expect(page.getByRole("link", { name: "查看所在付款批次", exact: true })).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
|
||||
await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => fulfillJson(route, { ...reimbursementDetail("approved"), processingProgress: { stages: [] } }));
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("reimbursement-action-panel")).toContainText("付款进度暂不可用");
|
||||
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("R7E 全站高风险操作二次确认", () => {
|
||||
test("报销删除和敏感导出必须确认后才调用接口", async ({ page }) => {
|
||||
const calls = await mockSystemActionApis(page);
|
||||
|
||||
+3
-1
@@ -70,7 +70,9 @@ A 类页面优先保证真实试运行路径顺畅;B 类页面优先保证台
|
||||
|
||||
全系统长表单默认支持草稿和清晰提交。保存草稿不触发审批、付款、发布或外部同步;提交前必须做业务校验和摘要确认;离开未保存页面要提示;错误提示必须定位到字段、明细、发票、附件或材料区,并保留用户已输入内容。移动端表单只保留必要字段、材料上传和底部主动作,不承接复杂配置表单。
|
||||
|
||||
全系统详情页采用统一证据结构。状态与下一步先行,业务摘要其次,明细、发票、附件、合同文件、资产材料、回单等证据居中,审批/处理历史靠后,当前动作面板固定在 PC 右侧或移动端底部。普通详情与长流程的历史位于主列证据之后;短审批流程可把完整历史放在 PC 右栏动作之后,但主列不得重复,移动端仍位于正文之后。高风险动作不得放在用户看不到证据的位置,确认时必须展示对象摘要和后果。
|
||||
全系统详情页采用统一证据结构。状态与下一步先行,业务摘要其次,明细、发票、附件、合同文件、资产材料、回单等证据居中。PC 主列展示业务证据,右栏上方是操作及下一步,下方是当前处理进度,正文不重复当前进度;操作卡不能单独吸顶遮挡其后的进度。移动端保持单列,在正文后按下一步/操作说明、进度、完整历史排列,必要主动作保留底部安全区。完整审批/处理历史与当前进度不是同一内容:长历史可保留在主列证据之后或独立历史区域,既有短历史可在侧栏进度之后完整保留,不能因布局调整截掉意见。高风险动作不得放在用户看不到证据的位置,确认时必须展示对象摘要和后果。首批应用于报销详情、报销审批工作台和付款详情,其他模块按任务分批对齐。
|
||||
|
||||
动作与下一步必须服从同一份服务端业务阶段。报销已审批不等于尚未组批,付款已复核或制单已下载不等于银行已付款;已有批次的详情只保留一个有权限的批次入口。无读取链接不等于没有批次,未知/不完整进度不能猜测创建资格。验收需从关联业务处理后返回原详情,并覆盖重新进入、切回页面和请求失败,不以单页按钮成功代替跨页交接正确。
|
||||
|
||||
全系统证据材料统一在详情页集中处理。列表页和台账页只显示发票、附件、合同文件、回单、资产材料等证据的完整性摘要、缺失提示和进入详情入口,不直接承接预览、下载、删除、批量打包或敏感材料处理;这些动作进入详情页、详情命令区、`更多` 或授权审计路径。
|
||||
|
||||
|
||||
@@ -278,6 +278,38 @@ async function main() {
|
||||
assert.ok(task, `pending task for ${role}`);
|
||||
if (role === "manager") assert.equal(task.assignee_id, manager.id);
|
||||
else { assert.equal(task.assignee_id, null); assert.equal(task.assignee_role, role); }
|
||||
if (role === "finance" && browser && preview) {
|
||||
await prisma.workflowHistory.updateMany({ where: { instance_id: newWorkflowId, action: "approve" }, data: { comment: "synthetic historical comment ".repeat(12) } });
|
||||
const clientRequire = createRequire(path.join(root, "client/package.json"));
|
||||
const { expect } = clientRequire("@playwright/test");
|
||||
const uiOrigin = `http://127.0.0.1:${preview.httpServer.address().port}`;
|
||||
for (const mobile of [false, true]) {
|
||||
const context = await browser.newContext({ serviceWorkers: "block", viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 960 } });
|
||||
try {
|
||||
const fingerprint = `synthetic-approval-layout-${mobile}`;
|
||||
await context.addCookies([{ name: "hyb_oa_device_fingerprint", value: fingerprint, url: uiOrigin }]);
|
||||
const login = await context.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: approvers.finance.email, password: loginPassword } });
|
||||
assert.equal(login.status(), 200);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${uiOrigin}${mobile ? "/mobile/approvals" : "/workflow/tasks"}/${task.id}`);
|
||||
const progress = page.getByTestId("reimbursement-approval-processing-progress");
|
||||
await expect(progress).toHaveCount(1);
|
||||
await expect(page.getByTestId("reimbursement-workflow-history")).toContainText("synthetic historical comment");
|
||||
if (!mobile) {
|
||||
const actions = page.getByTestId("workflow-task-action-panel");
|
||||
const actionBounds = await actions.boundingBox();
|
||||
const progressBounds = await progress.boundingBox();
|
||||
assert.ok(progressBounds.y > actionBounds.y + actionBounds.height);
|
||||
assert.ok(Math.abs(progressBounds.x - actionBounds.x) < 35);
|
||||
assert.notEqual(await actions.locator("..").evaluate((element) => getComputedStyle(element).position), "sticky");
|
||||
} else {
|
||||
await expect(page.getByTestId("mobile-approvals-sticky-actions")).toBeVisible();
|
||||
}
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
passed.push("approval: real finance desktop/mobile evidence, current progress and synthetic long-history fixture; non-overlapping aside and safe-area action");
|
||||
}
|
||||
const approval = await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id);
|
||||
assert.equal(approval.status, 200, `${role} approval: ${approval.status} ${approval.body.error?.code ?? ""}`);
|
||||
assert.equal((await post(`/workflow/tasks/${task.id}/approve`, {}, approvers[role].id)).status, 409);
|
||||
|
||||
@@ -23,11 +23,22 @@ module.exports = async function verifyPaymentExperience({
|
||||
account_number_encrypted: encryptStringOrThrow(app.config, `6217${"0".repeat(8)}1234`),
|
||||
} });
|
||||
await prisma.featureFlag.create({ data: { flag_key: "payment_batch_v1", override_mode: "force_on", rollout_type: "global", department_ids: [] } });
|
||||
const readProgress = async (actor) => {
|
||||
const response = await fetch(`${origin}/api/reimbursements/${form.id}`, {
|
||||
headers: { authorization: `Bearer ${tokens.get(actor.id)}` }, signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return (await response.json()).data.processingProgress;
|
||||
};
|
||||
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_created").state, "current");
|
||||
const created = await post("/payment-batches", {
|
||||
entityType: "reimbursement", entityIds: [form.id], defaultPaymentChannel: "bank",
|
||||
}, approvers.finance.id);
|
||||
assert.equal(created.status, 201, `create payment batch: ${created.body.error?.details?.reasonCode ?? created.status}`);
|
||||
const batch = created.body.data.item;
|
||||
const reviewStage = (await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_batch_review");
|
||||
assert.equal(reviewStage.state, "current");
|
||||
assert.equal(reviewStage.detail, "待非制单人复核付款");
|
||||
const download = (actor) => fetch(`${origin}/api/payment-batches/${batch.id}/payment-order`, {
|
||||
method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${tokens.get(actor.id)}` },
|
||||
body: "{}", signal: AbortSignal.timeout(15000),
|
||||
@@ -35,6 +46,7 @@ module.exports = async function verifyPaymentExperience({
|
||||
assert.equal((await download(approvers.finance)).status, 409);
|
||||
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.finance.id)).status, 403);
|
||||
assert.equal((await post(`/payment-batches/${batch.id}/review`, {}, approvers.ceo.id)).status, 200);
|
||||
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").state, "current");
|
||||
assert.equal((await download(approvers.ceo)).status, 403);
|
||||
assert.equal((await download(employee)).status, 403);
|
||||
for (const count of [1, 2]) {
|
||||
@@ -50,7 +62,10 @@ module.exports = async function verifyPaymentExperience({
|
||||
const source = await prisma.reimbursementForm.findUnique({ where: { id: form.id } });
|
||||
assert.equal(source.status, "approved");
|
||||
assert.equal(source.paid_at, null);
|
||||
assert.equal((await readProgress(approvers.finance)).stages.find((stage) => stage.key === "payment_and_receipt").detail,
|
||||
"已生成银行制单,等待付款完成并上传回单,非审批环节");
|
||||
}
|
||||
assert.equal((await readProgress(employee)).stages.some((stage) => stage.targetHref), false);
|
||||
const audits = await prisma.auditLog.findMany({ where: { target_id: batch.id, action: { in: ["PAYMENT_ORDER_EXPORTED", "PAYMENT_ORDER_REEXPORTED"] } } });
|
||||
assert.equal(audits.length, 2);
|
||||
for (const audit of audits) {
|
||||
@@ -70,7 +85,13 @@ module.exports = async function verifyPaymentExperience({
|
||||
const login = await context.request.post(`${uiOrigin}/api/auth/login`, { headers: { "x-device-fingerprint": fingerprint }, data: { identifier: approvers.finance.email, password: loginPassword } });
|
||||
assert.equal(login.status(), 200);
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${uiOrigin}/payment-batches?id=${batch.id}`);
|
||||
await page.goto(`${uiOrigin}${mobile ? "/mobile" : ""}/reimbursements/${form.id}`);
|
||||
const sourceActions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel");
|
||||
await expect(sourceActions).toContainText("已生成银行制单");
|
||||
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0);
|
||||
const sourceLink = page.getByRole("link", { name: "查看所在付款批次", exact: true });
|
||||
await expect(sourceLink).toHaveCount(1);
|
||||
await sourceLink.click();
|
||||
const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel");
|
||||
await expect(actions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
|
||||
await actions.getByText("更多", { exact: true }).click();
|
||||
@@ -86,12 +107,22 @@ module.exports = async function verifyPaymentExperience({
|
||||
assert.equal(readFileSync(await downloaded.path()).subarray(0, 8).toString("hex"), "d0cf11e0a1b11ae1");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(sourceActions).toContainText("已生成银行制单");
|
||||
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0);
|
||||
await expect(sourceLink).toHaveCount(1);
|
||||
const sourceProgress = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress");
|
||||
await expect(sourceProgress).toHaveCount(1);
|
||||
const actionBounds = await sourceActions.boundingBox();
|
||||
const progressBounds = await sourceProgress.boundingBox();
|
||||
assert.ok(progressBounds.y > actionBounds.y + actionBounds.height);
|
||||
if (!mobile) assert.ok(Math.abs(progressBounds.x - actionBounds.x) < 35);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
passed.push("payment: real authenticated desktop/mobile browser downloads Chinese BIFF8 with no reason input; repeat warning and receipt action retained");
|
||||
passed.push("payment: real authenticated desktop/mobile reimbursement-to-batch download and return agree on stage, unique link, action-before-progress; no reason input; repeat warning retained");
|
||||
}
|
||||
passed.push("payment: real finance create, CEO review, unauthorized export refused, Chinese BIFF8 first/re-download without reason, audited and still unpaid");
|
||||
};
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const ts = require("../../../../client/node_modules/typescript");
|
||||
const { buildPaymentProcessingSemantics } = require("../../../src/services/paymentProcessingProgress");
|
||||
|
||||
const filename = path.resolve(__dirname, "../../../../client/src/lib/reimbursementPaymentHandoff.ts");
|
||||
const compiled = ts.transpileModule(fs.readFileSync(filename, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const clientModule = { exports: {} };
|
||||
new Function("exports", compiled)(clientModule.exports);
|
||||
const { resolveReimbursementPaymentHandoff } = clientModule.exports;
|
||||
|
||||
function project(status, options = {}) {
|
||||
const stages = Object.values(buildPaymentProcessingSemantics({ status, ...options }));
|
||||
return { stages: stages.map((stage) => ({ ...stage, actorName: null, targetHref: null })) };
|
||||
}
|
||||
|
||||
describe("报销付款下一步消费服务端阶段,不猜测批次存在性", () => {
|
||||
it.each([
|
||||
["draft", {}, "待非制单人复核付款"],
|
||||
["reviewed", { requiresBankReceipt: true }, "等待财务线下付款并上传回单,非审批环节"],
|
||||
["reviewed", { requiresBankReceipt: true, paymentOrderExportCount: 1 }, "已生成银行制单,等待付款完成并上传回单,非审批环节"],
|
||||
["reviewed", { requiresBankReceipt: true, receiptCount: 1 }, "银行回单已上传,等待财务登记付款完成,非审批环节"],
|
||||
["reviewed", {}, "付款复核已通过,等待财务登记付款完成,非审批环节"],
|
||||
["failed", {}, "付款登记失败,待财务核对后重试,非审批环节"],
|
||||
["cancelled", {}, "付款安排已取消,流程已终止"],
|
||||
["cancelled", { reviewedAt: "2026-09-11T00:00:00Z" }, "付款安排已取消,流程已终止"],
|
||||
])("%s 无读取链接也不允许重新组批 (%j)", (status, options, expected) => {
|
||||
const result = resolveReimbursementPaymentHandoff({ status: "approved", progress: project(status, options) });
|
||||
expect(result).toEqual({ nextStep: expected, canCreateBatch: false, targetHref: null });
|
||||
});
|
||||
|
||||
it("只有明确待组批投影才提供资格,入口权限仍由调用者检查", () => {
|
||||
const progress = project("unknown");
|
||||
progress.stages[0] = { ...progress.stages[0], state: "current", detail: "待财务组建付款批次" };
|
||||
expect(resolveReimbursementPaymentHandoff({ status: "approved", progress }).canCreateBatch).toBe(true);
|
||||
progress.stages[1].targetHref = "/payment-batches?id=synthetic";
|
||||
expect(resolveReimbursementPaymentHandoff({ status: "approved", progress }).canCreateBatch).toBe(false);
|
||||
});
|
||||
|
||||
it("保留服务端有权的同一个批次入口", () => {
|
||||
const progress = project("reviewed");
|
||||
progress.stages[2].targetHref = "/payment-batches?id=synthetic";
|
||||
expect(resolveReimbursementPaymentHandoff({ status: "approved", progress }).targetHref).toBe(progress.stages[2].targetHref);
|
||||
});
|
||||
|
||||
it.each([undefined, { stages: [] }, { stages: project("reviewed").stages.slice(1) }, project("unknown")])("缺失或未知阶段拒绝猜测", (progress) => {
|
||||
expect(resolveReimbursementPaymentHandoff({ status: "approved", progress })).toEqual({
|
||||
nextStep: "付款进度暂不可用,请刷新后重试", canCreateBatch: false, targetHref: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("已付款只展示完成事实,不把制单当付款", () => {
|
||||
expect(resolveReimbursementPaymentHandoff({ status: "paid", progress: project("executed") }).nextStep).toBe("付款已登记完成");
|
||||
expect(resolveReimbursementPaymentHandoff({ status: "paid", progress: project("reviewed") }).targetHref).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["draft", "reviewing", "rejected", "cancelled"])("%s 不生成付款动作", (status) => {
|
||||
expect(resolveReimbursementPaymentHandoff({ status, progress: project("reviewed") })).toBeNull();
|
||||
});
|
||||
|
||||
it("删除记录不生成付款动作", () => {
|
||||
expect(resolveReimbursementPaymentHandoff({ status: "approved", deletedAt: "2026-09-11T00:00:00Z", progress: project("reviewed") })).toBeNull();
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户