比较提交

...
2 次代码提交
作者 SHA1 备注 提交日期
wangsli 5d8cdf12b4 fix: 隔离门户待办身份并取消旧请求(09-11-reimbursement-payment-handoffs)
ci-minimal / check-minimal (push) Canceled after 0s
ci-e2e / e2e (push) Canceled after 0s
2026-09-11 20:21:46 +08:00
wangsli 9e41f107fa fix: 收窄详情按钮并校正响应式回归(09-04-reimbursement-state-gap-finance-view) 2026-09-11 20:21:45 +08:00
共修改 18 个文件,包含 668 行新增和 40 行删除
@@ -20,6 +20,18 @@ and verification contracts. Do not copy broad aesthetic guidance from
- 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. - 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. - 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. - Wrong: `approved => create batch`, or `!targetHref => no batch`. Correct: stage-derived next step and one server-authorized destination, including a conservative unknown state.
- Reimbursement desktop payment links use content width (`justify-self-start`) and the existing 44px button height, not grid stretching across the aside. The creation label is `创建批次`; it navigates to the existing pending view and never creates a batch itself. Keep mobile touch sizing and the generic action-panel layout unchanged. Verify actual desktop bounds and that navigation sends no batch-creation POST.
- When `PaymentBatchesPage` retains its `lg:hidden` mobile action bar, its desktop action card must keep `hidden lg:block` as well as `lg:static`; changing stickiness must not remove responsive visibility. The adjacent progress stays visible. Cover the narrow PC route in addition to the dedicated mobile route, including receipt-upload action switching.
## Convention: Portal Query Eligibility Is Not Finance Display Eligibility
- `MobilePortalPage` loads authenticated `GET /portal/home` independently of `canUseFinance`. That flag gates the finance card, not `businessHandoffs`; the server still filters each item by role, permissions, participation, department and maker-checker.
- Test employee and CEO separately on PC and mobile, starting in a fresh browser context directly at the destination. Assert the mobile request actually occurs, the authorized link appears, processing removes it after reread, and failures show retry rather than an empty list.
- A finance mobile mock plus an employee desktop test is not a role-by-device matrix. Real isolated authentication/HTTP/browser checks must exercise employee returned work and CEO independent review without granting either a finance role.
- Portal response caches are employee-scoped: PC uses `["portal-home", me?.id]`, mobile uses `["mobile", "portal", "home", me?.id]`, and both queries require an existing identity. Keep these prefixes so existing invalidation callers still match; do not use previous-key placeholder data. `staleTime: 0` causes a refetch but does not isolate identities, and clearing PWA Cache Storage does not clear the singleton React Query cache.
- Identity keys also require cancellation: both portal query functions consume their `signal` and pass it through `fetchPortalHome(signal?: AbortSignal)` to `apiRequestData`. Otherwise an old request's late 401 can refresh under the new session, return the new employee's payload and cache it under the old employee's key. Validate actual transport cancellation plus A→B→A with the final A response held, not only A→B or successful late responses. Same-identity 401 refresh must still succeed; do not disable authentication refresh globally to make the test pass.
- Validate account changes within one browser context and document, using the UI logout/login flow rather than clearing caches, reloading or creating a new context. After the second identity is visible, old handoffs must remain absent while its response is held, after an error, through retry and after the old identity's delayed request completes. Assert that a request was actually held so a fast empty result cannot hide stale rendering; cover PC and mobile. Synthetic visibility events must reach the window listener (`bubbles: true` when dispatched on document). Real-auth fixtures must resolve profile fields from the authenticated ID; `request.employee` is not the full `/me` profile.
- A scoped portal fix does not prove every application query or same-account permission change has correct cache lifecycle. Keep unrelated authentication/cache work separate, and preserve the distinction between synthetic API browser checks, real isolated authentication/HTTP and production validation.
## Scenario: Actionable Validation And Selectable Payment Channels ## Scenario: Actionable Validation And Selectable Payment Channels
@@ -612,6 +624,7 @@ reference, not a copy of Feishu or Ant Design branding.
- Mobile reimbursement approval is offline -> no evidence is loaded from IndexedDB and no approval draft is allowed; an explained disabled state remains visible. - Mobile reimbursement approval is offline -> no evidence is loaded from IndexedDB and no approval draft is allowed; an explained disabled state remains visible.
- Mobile edit/approval has a bottom action -> fixed bar is visible at the viewport bottom and final evidence remains reachable above it. No bottom action -> no fixed-action page padding. - Mobile edit/approval has a bottom action -> fixed bar is visible at the viewport bottom and final evidence remains reachable above it. No bottom action -> no fixed-action page padding.
- Current or historical assigned reviewers may read the authorized current evidence; unrelated employees remain forbidden. Material endpoints continue their own permission, governance, audit and short-lived WeCom grant checks. - Current or historical assigned reviewers may read the authorized current evidence; unrelated employees remain forbidden. Material endpoints continue their own permission, governance, audit and short-lived WeCom grant checks.
- Supplementary attachment browser tests locate the intended file within `approval-attachments` by its accessible `查看补充附件:<filename>` control, then activate that row's `删除附件`. Do not depend on the retired `.detail-evidence-card` reimbursement layout or restore old product markup for a test. Preserve object/consequence copy, zero DELETE requests on cancellation or fewer than two reason characters, and exactly one target-specific DELETE carrying a valid reason; a mock browser pass does not prove persisted or production deletion.
### 5. Good/Base/Bad Cases ### 5. Good/Base/Bad Cases
@@ -18,6 +18,10 @@
“统一”指信息层级、位置、动作与状态一致,不是让所有业务使用同一条审批链。当前处理进度与完整历史区分:前者固定在 PC 操作下方,历史内容不丢失、不伪装成当前待办。移动端沿既有壳与安全区动作,不扩复杂付款能力。 “统一”指信息层级、位置、动作与状态一致,不是让所有业务使用同一条审批链。当前处理进度与完整历史区分:前者固定在 PC 操作下方,历史内容不丢失、不伪装成当前待办。移动端沿既有壳与安全区动作,不扩复杂付款能力。
09-11 追加按钮优化:BusinessDetailActionPanel 的 grid 默认拉伸其子按钮,局部对报销付款主入口使用既有 justify-self-start 工具类,保留默认 h-11/px-4。创建文案缩为“创建批次”,查看入口保留现有明确文案;不改变通用操作卡、其他业务按钮或移动全宽触控规则。覆盖 1280/1440 桌面实际尺寸、创建入口目标/无写入、已组批隐藏与手机既有动作。frontend-design 不可用,本轮只维护已有按钮和 Tailwind 样式,不新增视觉体系。
扩展原付款页面回归发现 `PaymentBatchesPage` 的桌面操作卡仅设 `lg:static`,窄屏也可见,而同页另有 `lg:hidden` 的手机底栏,形成重复操作区。该页原布局验收要求仅一套可见操作,因此局部恢复 `hidden lg:block lg:static`,不隐藏它下面的进度。复用原“移动端固定操作区”测试验证上传前后动作切换,不删失败断言或改成允许重复;桌面入口与专用手机路由继续回归。
docs/产品设计规则.md 目前仍允许普通/长流程历史在主列,短流程在侧栏;待确认后应明确当前进度与完整历史的区别,统一已有页面和稳定规约。frontend-design 当前不可用,本阶段只复用已存在的付款侧栏样式;若实际需要新视觉方案,另行说明缺项,不擅自换设计体系。 docs/产品设计规则.md 目前仍允许普通/长流程历史在主列,短流程在侧栏;待确认后应明确当前进度与完整历史的区别,统一已有页面和稳定规约。frontend-design 当前不可用,本阶段只复用已存在的付款侧栏样式;若实际需要新视觉方案,另行说明缺项,不擅自换设计体系。
修复后重点不是单页截图,而是“批次创建/复核/制单之后返回报销详情”的跨页面状态一致性,覆盖不同角色、无批次与无权读批次的区别、缓存重新聚焦、取消/失败及接口缺项。不承诺只改前端:若既有 DTO 不能可靠表达动作资格,先核实并在本阶段补最小合同,不改付款写规则、角色或 schema。 修复后重点不是单页截图,而是“批次创建/复核/制单之后返回报销详情”的跨页面状态一致性,覆盖不同角色、无批次与无权读批次的区别、缓存重新聚焦、取消/失败及接口缺项。不承诺只改前端:若既有 DTO 不能可靠表达动作资格,先核实并在本阶段补最小合同,不改付款写规则、角色或 schema。
@@ -1,5 +1,135 @@
# 报销状态空隙与财务处理视角:实施计划 # 报销状态空隙与财务处理视角:实施计划
## 2026-09-11 迟到 401 复核接续(最新,门户取消修正本地通过)
主会话只读核对 dcde0d44 的 33 源文件、3 补丁重放及历史证据后,双端合成浏览器补查甲→乙→甲:甲旧请求迟到 401 后按乙会话重试,结果进入甲缓存,甲再次登录等待新响应时显示乙待办。该包 NO_GO;独立复核仍 PENDING_INLINE,原 6/62/真实隔离通过记录不改写。回执在 postpush-identity/readonly-review.md。
沿原批准门户隔离目标由 09-11 补请求取消,不改全局认证、权限或服务端,不新建任务。双端红测复现后,修正专项 10/10、联合三文件同轮 66/66(包含该 10 项)、新构建/PWA 与真实隔离业务链通过;临时库/合成附件已清理,135 单测按未变输入复用。新证据及修订树另存 postpush-cancellation,不覆盖 dcde0d44/NO_GO 或旧通过记录。
下一步新修订输入独立只读复核,明确授权后提交推送、冻结新 SHA,再刷新执行时预检并另批部署及执行端公网自动验收。主会话自检不抵扣独立 review;迟到 401 为合成 auth/API,未证明全局进行中刷新或其他查询生命周期。未提交、推送、部署或连接生产,不让员工代测。
## 2026-09-11 门户身份隔离本地闭合(最新,未提交/部署)
用户已确认,由 09-11 原 task 在既有 integration 副本修 PC/手机门户身份缓存;09-04 继续协调联合候选,不新建小任务。运行时相对上一冻结树仅改两页的员工身份 queryKey/无身份禁用门,不改认证、角色、服务端、seed 或付款流程。旧 postpush-recheck 冻结包和只读 P1 回执保留,新修订包在联合目录 postpush-identity,树与摘要以其 manifest.json 为准。
未修构建双端 slow 红测均复现;修后专项 6/6、联合三文件同轮 62/62(含这 6 项)、新构建/PWA 及真实隔离业务链均通过。真实链包含同文档 UI 退出登录、员工→CEO、身份确认及真实门户响应暂缓,零旧待办;临时库与合成附件已清理。首次专项事件夹具和真实链 /me 夹具失败原样保留,具体归因见 09-11 implement。135/135 单测仅按输入未变复用,不报重跑;主会话检查不抵扣独立 review。
下一步:新修订包独立只读复核 → 明确授权提交推送并冻结新 SHA → 执行时刷新生产版本/迁移、磁盘、备份及独立回滚制品 → 候选专属部署批准和执行端公网合成验收。当前不发布 fedcaed1 或旧修订树,不用本地通过抹去完整门禁、全站缓存、实时 OCR、公网身份与应用级恢复缺口。根 checkout 和既有恢复/付款准备文件不混入;未连接生产、未提交推送/部署,员工不代测。
## 2026-09-11 只读复核新增阻断:跨账号门户缓存(历史:当时待修)
本轮按“执行下一步”核对修订树 68f44e1531719c4d83308cc80494181f0d390803、33 个文件及原证据,未改冻结 manifest/补丁。主会话 inline 检查不是独立 review;源码检查与本地合成浏览器诊断发现 P1:同一浏览器甲退出、乙登录后,在乙的门户请求返回前仍展示甲的业务待办。账号控件已是乙,正确空响应返回后甲的待办才消失。生产未连接,不能据此称已发生线上泄露或服务端越权。
原因是门户 queryKey 不含员工身份、全局 QueryClient 跨登录保留缓存;现有退出只清认证与 PWA Cache Storage,不清 React Query。相关认证/cache 基础实现与生产基线源码一致,但未运行旧版本作历史复现,不能断言本补丁首次引入或此前必无风险。前次 56/56 的独立上下文测试没有覆盖同一文档切账号,不作废其原结果,也不能继续据此放行此候选。
- [x] 冻结输入摘要核对、主会话源码检查、PC 同文档双合成员工诊断及账号控件确认;未操作真实业务,未派独立 reviewer。
- [x] 证据和具体修复方案回到 09-11 原待办 task,不新建账号切换/测试小任务;本节是冻结树之外的任务回执。
- [ ] 确认后按 09-11 设计隔离 PC/手机门户查询身份,补同浏览器切账号、慢响应/失败/旧请求晚返回回归;不改角色、真实账号或付款状态机。
- [ ] 修复并验证后重新冻结候选交审;当前不进入提交推送或发布,不重新运行未受影响的整套门禁来掩盖新缺口。
诊断脚本、首次 fixture 前置失败及两次复现记录、覆盖边界见联合目录 postpush-recheck/readonly-review.md。下一步是确认这项身份隔离修复;独立 review、提交推送、执行时预检、部署及公网验收仍单列,不让员工代测。
## 2026-09-11 遗留附件用例接续(已批准本地核实与验证)
用户采纳“核实遗留附件用例、补验证和复核材料”的下一步,复用本 task,不创建测试子任务,不提交、推送、部署或连接生产。失败快照已显示补充附件文件名和“删除附件”按钮;当前共享材料组件使用 approval-attachments 分组,而旧用例仍依赖 .detail-evidence-card 和“删除”。先修正测试定位并实际执行,不能由快照单独推断删除业务已通过。
- [x] 核对失败快照、当前共享附件行与请求 mock,定位旧测试选择器和按钮名失效;不修改产品 DOM 或删除权限来迎合测试。
- [x] 使用既有材料分组和文件名定位正确附件,保留对象/后果/理由、取消和理由不足零请求断言;验证有效理由只向目标附件发送一次 DELETE。
- [x] 定向复跑后完整重跑原三个页面测试文件;旧失败日志和 postpush 冻结包不覆盖,不以分批去重冒充同轮全部通过。
- [x] 同步本任务与 09-11 的最新验证状态,准备 postpush-recheck 修订包;未变的运行时/单测/真实隔离输入核对后复用,不宣称重新执行。最终树、补丁及证据摘要以该目录 manifest.json 为准。
独立只读复核仍待完成;本次只处理已知测试缺口,不扩展生产附件搜索、历史数据清理或付款状态机。
实际结果:附件专项 1/1,原 visual-portal / reimbursement-content-consistency / system-action-confirmation 三文件同轮 56/56,均退出 0、零重试。取消、空理由及单字理由零 DELETE,有效理由只请求目标附件一次;mock 请求验证不等于持久化删除或生产验证。根因是共享材料布局变更后旧测试定位未同步,本轮不修改产品删除行为;对应可执行定位/确认合同补入既有前端规约。
运行时及 QA 输入与上一冻结树保持一致,135/135 单测、typecheck/build/PWA、真实隔离认证/HTTP/双端业务链继续复用,不是本轮重跑。原失败和中断证据及 postpush 的 27 份证据/两份补丁摘要核对不变;本轮补丁仅新增测试/规约/回执差异。下节“55 通过/1 未过”是上一冻结时点的历史记录,已由本节结果闭合,但完整项目门禁、独立复核、真实腾讯 OCR 和公网受控业务验收不因此通过。
下一步:修订树独立只读复核 → 明确授权后提交推送并冻结新候选 → 刷新发布前证据 → 另批部署与执行端公网合成验收。仍未提交、推送、连接生产或部署;不让员工承担基础测试。
## 2026-09-11 提交后复核与首批准入接续(本轮已批准,尚未发布)
当前批准的是本地规划、修复及验证;复用本任务与 09-11 待办任务,不创建新 task,不自动提交、推送、部署或执行生产合成写入。用户同轮追加操作按钮过宽、文字冗长,纳入本任务:创建入口改“创建批次”,桌面付款主入口按内容宽度,其他角色、状态和手机触控边界不变。
- [x] 完成按钮尺寸与短文案回归,核对点击只是进入既有付款页,不自动创建批次。
- [x] 修复扩展回归发现的付款 PC 路由窄屏双操作区;原隐藏断言失败证据保留,手机底栏及其动作切换不移除。
- [x] 联合 09-11 的手机非财务待办查询修正,完成新构建、定向浏览器及真实隔离角色链验证;旧 fedcaed1 不再是无已知缺陷的可部署候选。
- [x] 核对已推送旧候选及原证据,另存提交后封面和待提交修正包,不覆盖历史冻结输入。
- [x] 整理首批角色就绪、执行时预检、发布授权范围、公网自动验收与停止条件;未执行项保持未验证。
- [ ] 新修正另经提交推送授权冻结新 SHA,完成独立复核后再申请该候选的发布批准;员工不承担基础测试。
### 首批角色与资料:已知和待核分开
| 对象 | 已有记录可确认 | 仍需完成,不由旧证据推断 |
| --- | --- | --- |
| 普通员工、财务报销人 | 两人的直接经理及所属主体已修正回读,不再回填 | 正常身份认证、本人提交审批解析、本人收款资料及解密可用性;资料由授权维护人直接录系统 |
| CEO 审批/复核人 | 保留审批路由,单独 CEO 不授 AP 全量读取;既有付款复核业务反馈 | 新手机待办、公网角色深链与非本人制单复核;不摘角色、不替本人处理真实任务 |
| 财务付款人 | 公司主体/付款账户存在性及已有创建/下载业务反馈 | 自审拒绝、制单/回单权限、已组批返回原单及待办消失;不把他人的成功付款资料推及所有人 |
| 管理员 | 日常管理权限保留 | 个人报销不是默认首批需求;如本人需要,再单独核经理/主体/收款资料,不给全部 smoke 回填 |
### 发布与执行端公网验收清单(待批准执行)
1. 绑定新候选 commit/tree/补丁及验证证据;独立只读复核与主会话自检分列。旧 fe025e65/fedcaed1 审查或例外不能自动覆盖本次修正。
2. 发布前重新只读核对生产 build/image、迁移状态/checksum、磁盘及峰值余量、最新同源 DB/附件备份完整性和时点、当前独立镜像制品。早期 86%/8.93GB 不再引用为当前容量;40.04GiB 也只是前次观测。本轮没有刷新生产。
3. existing 模式保持不运行初始化 seed;迁移先于必需配置检查,不能宣称失败时零写库。本轮运行时修正不新增迁移、角色、seed 或银行渠道。
4. 恢复风险如实披露:数据库已恢复成功;配对检查发现一条无当前业务关联的历史附件缺失,用户已选择保留归档并标记、暂不追查;原 NO_GO/应用未验证不改成 PASS。镜像只能恢复代码,不能撤销数据库写入;恢复旧备份可能丢失之后业务。fe025e65 已认识留痕删除,不误写成本次一定要退到不认识删除标记的旧版本;如跨越更早版本仍需单独评估。
5. 同一份发布方案明确部署、专用测试身份、有限合成单据/发票/审批/批次/回单写入和清理范围,获批后连续验收,避免逐动作打断。只处理隔离标识的合成对象,审计留存,不重放真实员工单据;外部通知/银行连接关闭。真实腾讯 OCR 调用须使用获准票据及受控凭据,只回传结果,不把 mock 当实测。
6. 顺序为候选版本/ready/bundle → 专用普通员工及财务/CEO 认证 → 双端上传/OCR/选票建明细/分类编辑 → 提交/退回重提/留痕删除重建 → 审批 → 财务组批/本人自审拒绝/CEO 复核 → 中文 BIFF8 制单 → 返回原报销操作与进度一致 → 合成回单登记 → 对应待办消失。生产若没有获准的合成测试渠道,停止该段并保留未验证,不以登录页或 admin 登录抵扣。
7. 任何版本不一致、权限越界、状态错误、待办漏失、备份/迁移异常均停止对应阶段,保存证据;不自动回填/付款/清备份或凭不确定响应重试写入。回退须根据本次实际写入与已批准方案判断。
8. 执行端验收通过、剩余例外逐项批准后才开放首批正常业务。真实审批/网银付款仍由职责人办理,不称合成 paid 是银行付款成功。
### 并行债务排期(不叠入本次冻结代码)
- 09-06 门禁及已有 09-07 认证任务:先按历史诊断归因恢复可信登录前置与完整验收;不把旧失败数当确定产品缺陷,不默认运行可能部署/访问公网的 all 或 project-wide 入口。
- 09-05 错误解释:复用现有模块清单,09-18 首批验证、09-25 复审余量、09-30 前逐模块延期说明、10-02 查漏。673 条仍 10-05 到期,不统一续期。
- 08-23 恢复:完整附件/应用级隔离验证另排期,保留数据库 PASS 与历史缺失原结论;不再扩查旧发票或加复杂 runner。
- F1 付款读取纵深防御、其他详情页扩围和任务状态收尾仍归已有任务,随相关模块实施,不阻塞本轮本地验收也不冒充已经完成。
扩展检查发现同一付款 spec 两条手机断言相互矛盾:旧“固定操作区”要求桌面卡隐藏,旧“先制单”又要求该卡可见并用于测量进度。按已批合同,后者改为检查实际手机底栏可见/fixed/至少 44px,桌面仍测进度在操作下方;不删除可见性或付款动作断言。完整三文件运行还发现一条本轮未修改的“高敏附件删除”用例找不到旧 .detail-evidence-card 目标;保留失败,不推断生产附件不可删除,也不顺手改附件实现或删断言。作为既有门禁债的具体验证项记录,未通过的完整扩展结果不能冒充全绿。
### 本轮执行结果与下一步
- 代码:仅 3 个前端页面运行时变化,本任务按钮/窄屏与 09-11 手机待办同原任务交付,不改通用组件、服务端、角色、schema、seed 或付款状态机。两项定位失败后修复,付款既有断言冲突另行纠正;本轮不提交、推送、连接生产或发送消息。
- 验证:unit-complete.log 135/135、build-integrated.log typecheck/build/PWA、真实隔离 real-db-browser-final.log 均退出 0;真实密码登录/JWT/HTTP、PC/手机、上传选票/分类/退回重提/审批/组批/独立复核/中文 BIFF8/返回原单/合成回单登记以及两类手机待办冷进入 1→0 通过,临时容器和合成文件已清理。OCR 是真实 worker/mock provider,没有腾讯调用或银行连接。
- 页面:新角色组合 6 项和按钮 4 项先定向通过;扩展三文件原始 54/56,响应式冲突断言修正后 3/3 复跑,去重 55 项通过、历史附件选择器用例 1 项仍失败。不得写成全量 56/56;该用例本体及共享附件组件与 HEAD 相同,但未做旧候选运行时独立复现,不下“生产无问题”结论。
- 环境与治理:首轮系统 Python 缺 xlwt 引发导出测试 409,换用既有固定 BIFF8 环境,不改产品兜底;前后端测试均需显式传同一 PAYMENT_ORDER_PYTHON_BIN。143/130 中断及原失败日志保留,以完成回执为准。过程、Trellis、UI strict、原因码 strict、语法及 diff 检查通过;文档 registry 0 errors/1 warning(既有 22 项),不顺手清理。
- 交审:联合 postpush/review.md、manifest.json、supplement.diff 和 combined-changes.diff 区分原已推送候选与本地修正;原 32 文件/13 份证据及两笔补丁树核对通过。恢复/付款任务的既有未提交补记和根 checkout 改动不纳入修正补丁。
- 等待事项:独立只读复核、历史附件用例归因(由既有门禁任务接续)、明确提交推送修正授权、冻结新 SHA、执行时预检及本候选部署/公网合成验收授权。旧附件缺失按已确认处置保留;不重复回填、不删回滚制品、不让员工代测,不把阶段完成等同员工已可公网使用。
## 2026-09-11 当前两项窄授权(已完成,未部署)
用户在明确“保存并校验当前生产镜像,同时仅为财务和 yj 补齐唯一所属主体;不部署、不恢复、不修改银行资料”后回复“是”。本轮允许以下两项,不继承或扩大为管理员/smoke 回填、应用发布、docker load、备份恢复、业务单据操作或提交推送。
- [x] 刷新目标身份与当前 build/image/容量,使用已审镜像保存流程,只新建本次独立制品目录;配置摘要、全部层、归档摘要与大小通过,另一次 SSH 只读回读一致。
- [x] 复用 09-08 付款任务执行财务与 yj 的 legal_entity_id 空值→唯一启用主体:只读预检冻结身份,检查员工 UPDATE 触发器/规则,事务锁定、条件恰好两行更新、提交后新连接只读回读;又经单独 SSH READ ONLY 复核一致。
- [x] 补脱敏回执与实际剩余项;独立复核/ARM 管理员渠道/配对恢复/部署仍待处理。镜像回退不撤销数据库写入;本轮不写真实单据。
执行回执:10:46(Asia/Shanghai)当前 fe025e65 镜像独立保存并回读成功,归档 1,119,357,440 字节(约 1.04 GiB),SHA-256 为 5115ca42ac1c59e9726c8d92447e82080c2816bf9460196f430d04a66b1cf886;配置摘要与运行镜像一致、14/14 层通过,文件 0600/目录 0700,独立于 Docker 存储但仍同机,余量约 40.04 GiB。容器及启动时间未变,不做 load/恢复,不删旧制品。
10:50 先只读核对两人身份/角色/经理/原值、唯一主体及已审 UPDATE 触发器/规则;10:51 Serializable 事务仅条件更新 legal_entity_id 恰好两行,提交后新数据库连接回读一致,10:52 再经独立 SSH READ ONLY 回读。管理员、CEO、smoke、经理、角色、银行资料与单据未纳入写目标;未部署、未提交推送此次文档补记。
证据位于本副本 artifacts/qa-flow/20260911-authorized-preparation/:image-result.json、image-save-receipt.json、image-independent-readback.json、entity-precheck.json、entity-apply.json、entity-readback.json。修正程序 Node 语法检查及 13 项合成正反例通过,涵盖原值/角色/经理/唯一主体漂移、部分更新回滚、触发器变化与不确定提交不重试;合成测试不冒充真实数据库事务测试,实际写入/回读回执另列。
今日备份时点早于本次两字段修正,没有触发新备份;恢复旧备份会回退本次修正及其后业务数据,镜像回退不会撤销它们。当前已补齐镜像制品和两人主体缺项,不代表银行解密、真实提交/付款、DB+附件恢复或独立候选复核通过。下一步仍是独立复核及获得隔离目标/管理员渠道后的配对恢复;部署和公网合成验收另批。
## 2026-09-11 已授权提交推送与限定只读预检回执(最新,未部署)
用户回复“执行你推荐的这些下一步的工作”,本轮承接两笔提交推送及限定只读预检;不扩为保存镜像、恢复、生产资料回填、消息发送、部署或合成业务写入。inline 主会话复核继续单列,独立 reviewer 未执行,不把用户同意下一步写成独立复核已通过或发布例外获批。
- 提交:f9aee4c93272204d18408217358d95334cedcd5e(本 task,19 文件);fedcaed1a13cc3b32ee47e1d72c2b5ffa5512c57(09-11 待办,15 文件)。共享两份 QA 脚本按补丁拆分,临时 index 核对第一笔无第二笔投影逻辑、两笔最终树为 476364c76cf476e6af14f0f263599f4d5eced566,与原冻结树完全一致;没有新分支、amend、强推或移动根 checkout 的 main。
- 推送:origin/main 回读 fedcaed1,待推送 0。32 个最终变更文件及旧验证日志摘要均与未提交冻结包匹配,复用 135/135 单测、39/39 页面、真实隔离数据库/HTTP/双端联合验证,不假称本轮重跑全部测试。提交/推送回执及两笔补丁在本副本 artifacts/qa-flow/20260911-combined/,旧 manifest/changes.diff 未改。
- 生产预检:09:47–09:52(Asia/Shanghai),运行仍为 fe025e65,容器/启动时间不变;68 条迁移全部完成,pending/未知/异常/checksum 不匹配均 0。本轮候选无新增 migration/schema/seed/角色修改。数据库、附件、回滚目录所在文件系统均约 41.09 GiB 可用、30.3% 使用率,不需要本轮清理。
- 本地备份:今日 02:30:49 完成,核验时约 7.27 小时;manifest、DB 包、附件包大小/摘要一致,build/image 与当前生产匹配,PASS_INTEGRITY_ONLY。使用既有管理员 SSH 通道只读验真,未改权限;未恢复备份、未导出到开发机。
- 恢复制品:既有登记目录中 3 份非当前版本回执,没有匹配当前 fe025e65/image 的独立镜像制品;这是登记位置内的结论,不断言其他所有位置均不存在。旧制品保留,本轮没有 docker save/load、清理或重启。
- 异机:源端正式 last-success 对应今日批次,最后时效检查为 PASS_AUTOMATIC_BACKUP_FRESH、findings=0;ARM dead-man 今日 06:30 服务退出 0、timer active。ARM 普通账号读归档目录被拒,现有 sudo 非交互通道要求密码,未绕权限;今日 ARM 文件摘要/磁盘仍未独立核验,不能用源回执和 timer 成功代替。源摘要 last-run 使用 12 位 VITE_BUILD_SHA、last-success 使用完整 SHA;本轮探针误按全长比较已按正式脚本合同修正,失败记录保留,不认作备份故障。
- 员工:排除 smoke,仅核对已确认四个登录标识的最小必要字段。CEO 角色保留且无 AP 全量权限;财务/管理员保留原 AP 读取。财务和 yj 的直接经理均为已确认 CEO,且经理启用。四人的门户及付款部门 gate 均开启;腾讯 OCR 配置与 jobs 开启,未调用接口。
- 就绪缺项:CEO 所属主体、本人建行资料存在性、对应付款账户密文存在性均满足已查前置;财务/yj/管理员仍无所属主体,不能据此说他们自己的报销已具备付款条件。管理员还无直接经理、未关联有效建行机构;若本人也报销需另核修正范围。全公司当前唯一启用主体、1 个有效建行付款账户。这里只检查字段/权限/规则角色存在性,没有实际登录、提交解析或银行解密,不能说已证实全部人能走通,也不宣称已发生业务阻断。
本轮只读证据在本副本 artifacts/qa-flow/20260911-readonly/:production-precheck.json、backup-admin-check.json、employee-readiness.json、gates-transfer-final.json、arm-readonly.json、arm-admin-readonly.json。未读取真实单据金额/票据/银行明文/密码或个人访问历史,未写业务库。
下一步优先独立只读复核已推送候选,并单独批准保存校验当前 fe025e65 独立镜像;财务/yj 的主体回填是一笔独立生产数据授权,管理员是否纳入须按实际本人报销需要裁决,不顺手回填。配对 DB/附件恢复仍需明确隔离目标与管理员执行渠道,实际恢复/部署/公网合成写入另批。不让员工代测、不把历史 DB_RESTORE_PASS 泛化为本候选配对恢复。
以下为历史本地实施回执;本节覆盖其中未提交/未推送状态。本次执行回执是候选之外的文档补记,未自动再提交或归档。
## 2026-09-11 本地联合验收回执(最新,未提交/未部署) ## 2026-09-11 本地联合验收回执(最新,未提交/未部署)
- 代码:付款下一步统一消费服务端进度;只有明确未组批才显示创建入口,已存在批次使用授权深链,无权/未知不猜。报销详情与报销审批工作台统一右栏操作→当前进度→适用的完整历史,手机保留单列和底栏动作;局部重入/聚焦刷新不覆盖编辑态。 - 代码:付款下一步统一消费服务端进度;只有明确未组批才显示创建入口,已存在批次使用授权深链,无权/未知不猜。报销详情与报销审批工作台统一右栏操作→当前进度→适用的完整历史,手机保留单列和底栏动作;局部重入/聚焦刷新不覆盖编辑态。
@@ -9,6 +9,8 @@
- 操作、下一步和进度必须消费同一份权威付款阶段,不再各自仅由报销状态猜测。只有明确处于待组批且当前用户有组批权限时才展示组批入口;已存在有效批次时进入该批次,不重复组建。 - 操作、下一步和进度必须消费同一份权威付款阶段,不再各自仅由报销状态猜测。只有明确处于待组批且当前用户有组批权限时才展示组批入口;已存在有效批次时进入该批次,不重复组建。
- 复用既有服务端 processingProgress、付款阶段语义和权限控制;批次入口不可因缺少 targetHref 而推断“未组批”。数据缺失、请求失败或不完整时不展示猜测性的创建入口。 - 复用既有服务端 processingProgress、付款阶段语义和权限控制;批次入口不可因缺少 targetHref 而推断“未组批”。数据缺失、请求失败或不完整时不展示猜测性的创建入口。
- PC 统一为主列业务内容/材料,右栏上方操作及下一步、下方当前处理进度;进度不在主列重复。长历史按既有授权完整保留,可折叠或进入历史区域,不能因侧栏变窄截掉意见/记录。手机保持单列及底栏动作,同类页面状态/下一步/进度顺序一致,不强做双栏。 - PC 统一为主列业务内容/材料,右栏上方操作及下一步、下方当前处理进度;进度不在主列重复。长历史按既有授权完整保留,可折叠或进入历史区域,不能因侧栏变窄截掉意见/记录。手机保持单列及底栏动作,同类页面状态/下一步/进度顺序一致,不强做双栏。
- 09-11 用户追加:报销详情“去组建付款批次”主按钮改为“创建批次”,桌面创建/查看批次入口按内容宽度左对齐,不铺满侧栏。仍是进入付款页而不是自动创建;状态、权限、目标与查询参数不变,触控高度维持既有 44px,手机不照搬桌面窄按钮。
- 联合回归发现原付款详情 PC 路由在窄屏同时展示桌面操作卡和手机底栏;属于本轮三类详情布局一致性范围。窄屏只保留手机底栏的同一动作模型,处理进度仍可见,不改批次状态或付款动作权限。
- 首批闭合报销详情、报销审批工作台与付款批次详情的状态/位置一致性;其他业务详情先列实际差异再按归属分批调整,不以“统一”重写全部页面、复制审批引擎或改变各业务状态机。 - 首批闭合报销详情、报销审批工作台与付款批次详情的状态/位置一致性;其他业务详情先列实际差异再按归属分批调整,不以“统一”重写全部页面、复制审批引擎或改变各业务状态机。
- 按已批准体验复用现有侧栏/进度样式,不引入新视觉体系。全系统位置原则需在方案获批后同步既有产品规则及共享组件规约,避免现有规则仍允许两种位置。 - 按已批准体验复用现有侧栏/进度样式,不引入新视觉体系。全系统位置原则需在方案获批后同步既有产品规则及共享组件规约,避免现有规则仍允许两种位置。
@@ -15,12 +15,12 @@
"branch": null, "branch": null,
"base_branch": "main", "base_branch": "main",
"worktree_path": null, "worktree_path": null,
"commit": "5fe9f78", "commit": "f9aee4c93272204d18408217358d95334cedcd5e",
"pr_url": null, "pr_url": null,
"subtasks": [], "subtasks": [],
"children": [], "children": [],
"parent": null, "parent": null,
"relatedFiles": [], "relatedFiles": [],
"notes": "历史业务提交:R2=8e0af82,R1=5fe9f78;删除73fcd5a6随fe025e65于09-10发布。09-11付款下一步/侧栏修复与09-11待办投影按补丁联合,本地135单测、39页面和真实隔离流程通过。基线fe025e65不含本次未提交改动;独立复核、提交推送、生产就绪/恢复及发布验收待执行。详情见implement最新回执,不归档。", "notes": "09-11复核dcde0d44发现旧请求迟到401污染旧身份缓存,双端甲→乙→甲已合成复现,原包NO_GO保留。沿原批准门户隔离范围接续取消:两页queryFn消费signal,经门户API传给HTTP,不改全局认证、角色或服务端。双端红测后最新专项10/10、联合66/66零重试、新构建/PWA及真实隔离业务链PASS,临时库/合成附件已清理;135单测按未变输入复用。新包postpush-cancellation,独立review仍PENDING_INLINE;需新输入交审、明确提交推送授权、新SHA执行时预检及另批部署/公网自动验收。本轮未提交推送、连接生产或部署,不声称全站认证缓存或真实过期会话已验证。",
"meta": {} "meta": {}
} }
@@ -1,9 +1,24 @@
# 最小待办交接投影 # 最小待办交接投影
## 2026-09-11 复核接续:取消旧身份门户读取
原批准的双端身份隔离仍未完整成立:迟到 401 会按当前会话刷新并重放旧请求,乙结果可进入甲 key;双端甲→乙→甲已在冻结树 dcde0d44 的合成浏览器复现。此次沿原目标修复,不改全局认证、角色或生产。只为 fetchPortalHome 增加可选 AbortSignal,两个 queryFn 消费并传递它,使旧页面卸载时 Query 层取消、传输层终止,不让旧结果缓存。
新增双端迟到 401 的真实请求取消断言及往返登录验证,并检查同身份正常 401 刷新仍可用。不把“请求成功返回”当作全部晚到场景。原 postpush-identity 包与 NO_GO 回执保留;新构建、专项/联合及真实隔离结果另存 postpush-cancellation,独立复核仍单列。
## 2026-09-11 复核后追加:门户查询按身份隔离(已批准本地实施)
- 修复前 PC ["portal-home"] 和手机 ["mobile", "portal", "home"] 跨账号共享 QueryClient 数据;staleTime: 0 只触发后台刷新,不禁止先展示已有缓存。双端合成浏览器均已在未修构建上复现乙登录后等待网络期间显示甲待办,失败证据保留在 postpush-identity/red.log。
- 在这两个现有 queryKey 后追加已确认员工 id,并以身份存在性控制查询;不以 canUseFinance 控制整个请求。保持原前缀,使既有 invalidateQueries 前缀调用仍命中;不使用保留上一 key 数据的 placeholderData/keepPreviousData。
- 不修改全局认证或通过给角色加权限解决;按身份分区使旧身份请求晚返回也不能覆盖当前门户。既有认证/PWA 清理不等于 React Query 清理,其他模块的全局缓存生命周期风险不能由本修复推断已全部解决。
- 验收须在同一页面文档切换两个合成员工:新身份已生效但门户响应未返回、响应失败及旧请求晚到时均零旧身份待办;正确响应只显示新身份事项。覆盖 PC/手机、现有角色与失效刷新。不用真实员工手测或强刷“修复”缓存。
服务端服务 `loadReimbursementPaymentHandoffs` 从业务记录只读构造 `{ total, items }`,挂在现有 portal home DTO。项目为小团队,四类活动对象按稳定 key 去重,total 从返回 items 计算;不新建队列数据库或通用任务系统。资料未齐不是忽略该报销的理由,点击进入原业务页由权威 readiness/写事务决定能否组批。 服务端服务 `loadReimbursementPaymentHandoffs` 从业务记录只读构造 `{ total, items }`,挂在现有 portal home DTO。项目为小团队,四类活动对象按稳定 key 去重,total 从返回 items 计算;不新建队列数据库或通用任务系统。资料未齐不是忽略该报销的理由,点击进入原业务页由权威 readiness/写事务决定能否组批。
只选择必要字段。退回检查最新流程最后的 return/reject 事实,禁止把历史曾退回但当前主动撤回的草稿重新标成待修改。付款查询限 reimbursement,exclude deleted/paid,排除非取消批次占用;部门 gate 在本次加载内缓存,不解密银行资料。批次复核使用原 manager/ceo+review 资格及非制单人条件;付款处理仅 manage。 只选择必要字段。退回检查最新流程最后的 return/reject 事实,禁止把历史曾退回但当前主动撤回的草稿重新标成待修改。付款查询限 reimbursement,exclude deleted/paid,排除非取消批次占用;部门 gate 在本次加载内缓存,不解密银行资料。批次复核使用原 manager/ceo+review 资格及非制单人条件;付款处理仅 manage。
门户行动行复用现有组件,旧 finance pending/failed 聚合行只作为旧服务端 DTO 兼容,收到新投影时不重复列入行动计数;独立风险提醒不冒充付款复核。移动端复用同一 items,报销使用 mobile 深链,付款沿既有移动边界。只读查询错误使用页面已有错误反馈,不回落成零待办。 门户行动行复用现有组件,旧 finance pending/failed 聚合行只作为旧服务端 DTO 兼容,收到新投影时不重复列入行动计数;独立风险提醒不冒充付款复核。移动端复用同一 items,报销使用 mobile 深链,付款沿既有移动边界。只读查询错误使用页面已有错误反馈,不回落成零待办。
09-11 提交后复核发现:手机 portalHomeQuery 仍以 canUseFinance 启用,旧财务聚合的展示资格意外限制了新增业务待办的数据加载。修复仅移除整个已认证门户查询上的财务开关,保持财务卡片的 canUseFinance 判断和服务端角色/归属/部门/非本人制单过滤;不通过给员工增加财务权限绕过缺陷。补 employee 与 ceo 独立浏览器上下文的手机直接进入测试,不能只测 finance 手机或 employee PC。
无 schema/迁移/seed/业务写入/消息发送;已有通知机制不变,通知丢失不丢业务事实。每次渲染不是批准执行,写接口仍独立授权。代码回退恢复旧门户,不改业务记录。独立 reviewer 受 inline 限制时明确 pending。 无 schema/迁移/seed/业务写入/消息发送;已有通知机制不变,通知丢失不丢业务事实。每次渲染不是批准执行,写接口仍独立授权。代码回退恢复旧门户,不改业务记录。独立 reviewer 受 inline 限制时明确 pending。
@@ -1,5 +1,80 @@
# 执行清单 # 执行清单
## 2026-09-11 只读复核后取消旧请求(原范围接续,未提交/部署)
冻结树 dcde0d44 的双端合成浏览器复核发现:甲旧请求在乙登录后返回 401,会用乙 token 重放,并把乙结果写到甲 queryKey;甲再次登录等待新响应时显示乙待办。原 6/62/真实隔离通过记录不改写,它们未覆盖该分支。只读回执见 postpush-identity/readonly-review.md。本次沿原批准的门户隔离范围返回实施,不改变全局认证或业务权限,不新建任务。
- [x] 补双端迟到 401、真实传输取消、甲→乙→甲回归,先在现有未取消构建上跑红;补同身份刷新正例。
- [x] 两页 queryFn 消费 signal,经现有门户 API 传给 HTTP 请求;不改全局认证、业务写入或其他查询。
- [x] 新构建、专项、联合页面与真实隔离验证;保留失败证据,不重复累加数量。
- [x] 主会话检查、更新稳定合同与两任务状态;新修订树及补丁重放以 postpush-cancellation/manifest.json 为准,独立复核、提交推送和部署继续分列。
实际结果:未取消构建双端红测均失败;取消修正后专项 10/10,三个既有文件同轮 66/66、退出 0、零重试(包含这 10 项)。新 typecheck/build/PWA 与真实隔离数据库/认证/HTTP/双端完整业务链通过,临时库和合成附件已清理。14 个验证源输入、115 个构建文件在冻结前核对未变;135 单测仅按未变输入复用。迟到 401 专项为合成 auth/API,不冒充真实过期会话实测;真实业务链的普通登录与切账号另有证据。
本轮复核/修复只触及旧门户请求未取消这一根因,主会话自检不抵扣独立 review。原 dcde0d44 包与 NO_GO 回执保留;不将同会话修补写为独立通过,不改全局认证或其他模块缓存。下一步将 postpush-cancellation 新输入交审,再按明确授权提交推送、冻结新 SHA、执行时预检及部署/公网自动验收。未提交、推送、连接生产或部署。
## 2026-09-11 身份隔离修复接续(用户已确认,本地实施与验证)
官方 current 在实际 integration 副本切至本 task,联合协调仍由 09-04 记录;复用原任务,不新建修复 task。批准范围是 PC/手机门户身份缓存隔离与自动验证,不含提交、推送、部署、生产资料或全局认证改造。原 readonly-review/P1 及冻结输入保持原样;新证据另存 postpush-identity。
- [x] 在现有 visual-portal spec 补同文档账号切换矩阵,先用未修正构建复现 PC/手机慢响应串显,再修两个门户 queryKey/无身份禁用门。
- [x] 同一 BrowserContext/文档验证双端慢响应、失败重试、旧请求晚返回;不清缓存、不强刷、不用新上下文替代切账号。保留已有前缀刷新及角色权限。
- [x] 新构建、定向页面、联合三文件及受影响真实隔离链路验证;本地通过与生产/独立 review 分列,未变服务端单测按输入核对复用。
- [x] 主会话检查本轮差异、补稳定 spec 和两项原任务状态;另存修订包,不覆盖旧复现或把阶段结束当上线。
本地结果:未修构建 PC/手机 slow 两项均复现串显;修后身份隔离专项 6/6,联合三文件同轮 62/62、零重试,包含该 6 项,不重复累加。新 typecheck/build/PWA 通过;真实隔离 PostgreSQL/密码登录/JWT/HTTP/双端业务链 PASS_REAL_DB_HTTP,新增同文档员工→CEO 登录、身份回读、真实门户响应暂缓和零旧待办断言通过,临时库/合成附件已清理。135/135 单测按服务端、共享前端 helper 和依赖清单未变核对复用,不称重跑。证据见联合目录 postpush-identity;具体冻结树、摘要和检查结果以该目录 manifest.json 为准。
复盘:固定 queryKey 隐含“浏览器只使用一个账号”,原独立上下文和冷进入测试漏掉会话生命周期。原始红测有效保留;修后首次专项 4/6 的两项失败是测试事件未冒泡到 window,首次真实链失败是 /me 夹具从精简 request.employee 读取了不存在的工号字段,均在身份/刷新前置阶段失败。修正事件和按认证 ID 查隔离库资料后重跑通过,没有放宽断言或更改认证实现。原失败日志仍在,不把夹具失败说成新增生产缺陷。
本轮 P1 在已测门户范围本地闭合,独立只读复核仍 PENDING_INLINE。未提交、推送、连接生产或部署;其他查询缓存、同账号权限变更、完整发布门禁、公网角色验收和正式备份应用级恢复不由本轮 PASS 推导。下一步交审新修订包,经明确提交推送授权冻结新 SHA,再更新执行时预检并另批部署/公网自动验收;员工不代测。
## 2026-09-11 只读复核 P1:同浏览器切账号待办串显(历史:当时方案待确认)
主会话检查冻结树 68f44e1531719c4d83308cc80494181f0d390803,使用本地合成 API/真实浏览器复现:甲通过界面退出、乙登录,同一文档且账号控件已显示乙,乙的门户请求尚未返回时仍显示甲的待办。正确空响应返回后消失;没有访问生产或验证服务端越权。PC 已实测,手机仅发现同类固定 queryKey,尚未复现。不重写此前 56/56、135/135 及真实隔离链路通过记录。
本轮只读检查与诊断未改运行时,旧冻结输入保留,当前候选不放行。首次诊断 /me fixture 漏 requestId,登录未通过;修正 fixture 后两次观察一致,后一次额外断言账号控件已切乙。细节见联合目录 postpush-recheck/readonly-review.md;不是独立 reviewer 结论。
- [ ] 确认后将 PC/手机门户缓存键按当前员工身份区分;无已确认身份不查询,不沿用上一身份数据或用强刷掩盖问题。保持现有前缀失效刷新、冷进入和财务卡权限。
- [ ] 在同一 BrowserContext、同一页面文档通过真实页面退出/登录路径覆盖双端,不调用清缓存/新建上下文/强刷;分别验证慢响应、失败和旧身份请求晚到均不出现他人待办。
- [ ] 保留已有角色正反例和状态消失回归;改后构建并对受影响真实隔离链路回归,再冻结新输入。认证 API、角色、生产数据和付款写入不在此修复范围;其他模块全局认证缓存另行归因,不宣称两页修复解决全站缓存。
## 2026-09-11 联合附件回归接续(最新本地验证,未提交/部署)
复用 09-04 原任务核实剩余高敏附件失败:旧材料 CSS/按钮名定位未随共享组件同步,附件在失败快照中实际存在。仅适配该测试并补单字理由/取消关闭断言,不改运行时、角色或删除规则。专项 1/1;原三份页面文件同轮 56/56、退出 0,包含本任务普通员工/CEO 双端待办冷进入、处理后消失和手机错误重试。下文“55 项通过/1 项未过”保留为上次冻结记录,不再是当前缺口。
运行时和 QA 输入与上一冻结树一致,复用 135/135 单测、构建/PWA 和真实隔离 HTTP/双端业务结果,不称本轮重跑。新复核材料见联合目录 postpush-recheck,旧 postpush 输入/失败日志不覆盖;附件专项为 mock 请求验证,不冒充生产或持久化删除。独立复核、完整门禁、明确提交推送修正授权、新候选执行时预检及部署/公网合成验收仍未完成,员工不代测。
## 2026-09-11 提交后复核与首批上线准备(本轮已批准,未部署)
用户批准按综合建议规划并实施。复用本任务修待办缺陷,09-04 维护联合交审与发布验收清单;不新增细碎 task,不继承提交/推送/部署/生产写入授权。执行副本为既有 integration,官方 current 已切换并回读为本 task。主会话 inline 实施/检查,不派 implement/check 子代理,独立 review 继续单列。
已推送 fedcaed1 的只读复核发现手机门户整体查询仍受 canUseFinance 限制:API 能返回普通员工退回事项,不代表该角色手机真正发起了查询;旧浏览器覆盖只有 finance 手机、employee PC。此前 135/39 通过记录保留,但不能证明这两个角色端组合通过,也不继续把 fedcaed1 标成无已知阻断的发布候选。
- [x] 先追加手机普通员工与单独 CEO 的冷进入回归,在旧代码上复现;保留失败证据。
- [x] 移除手机整个门户查询的财务展示门,财务卡片/服务器权限不变;补处理后消失和失败重试。
- [x] 运行定向浏览器、typecheck/build、有关真实隔离链路和治理检查;失败逐项归因,不让真实员工代测。
- [x] 核对旧候选 32 文件、两笔提交树与冻结证据;另存提交后交审封面、此次修正补丁及摘要,不改写旧 manifest/review。
- [x] 在 09-04 原计划写清首批角色剩余就绪项、发布前重查、受控公网验收与停止条件;旧附件保留标记不再追查。
- [ ] 新修正经明确提交推送授权后冻结新 SHA;独立复核、发布审批与公网验收仍待完成,不把本地修复当上线。
并行技术债继续沿既有 09-06 门禁、09-05 错误解释及 08-23 恢复任务排期。10-05 的 673 条到期记录不统一续期,主线不顺手改 F1 付款读取或其他模块。
复盘:根因是新增业务 DTO 后仍沿用旧财务卡片的查询开关,属于变更传递遗漏与角色×端覆盖缺口。原 API 回归和财务手机测试没有验证普通员工/CEO 的手机查询是否发出。针对旧代码的两个手机冷进入用例均失败(待办链接为 0),修正后 employee/ceo × PC/手机冷进入/处理后消失,加手机错误重试共 6 项通过。服务端权限、角色和持久化模型未改;后续真实隔离测试额外断言手机真正发起 portal 请求,而不是仅验证接口返回。经验已补既有前端规约,不新增经验文档。
最终本地回执:证据集中于联合目录的 postpush/。当前全部运行时修正仅 3 个前端页面(本任务手机待办、09-04 报销按钮/付款窄屏),服务端未改。unit-complete.log 135/135、build-integrated.log typecheck/build/PWA、real-db-browser-final.log PASS_REAL_DB_HTTP 且两类手机待办各自真实认证冷进入 1→0 通过;临时库/合成附件已清理。三文件扩展 ui-final.log 原始 54/56,手机断言按响应式合同纠正后 responsive-final.log 3/3,去重为 55 项已通过、1 条未改动的历史附件选择器用例仍失败;没有把扩展检查写成全绿。独立 review、提交推送修正、部署/公网验收均待完成。原 39 项与本轮回归不重复累加,腾讯实时 OCR 仍未测试。
最新前置补记:用户另批后,当前生产 fe025e65 镜像已独立保存并完成配置/14 层/归档摘要及新 SSH 回读;财务/yj 的所属主体仅两行条件修正并独立连接回读通过,未改角色/经理/银行。共用 09-04/09-08 最新回执和联合副本 artifacts/qa-flow/20260911-authorized-preparation/;下文制品/两人主体缺失是此前阶段状态。候选 fedcaed1 未变,未部署;独立复核、ARM 管理员渠道和配对恢复仍未完成。
## 2026-09-11 提交推送回执(最新,未部署)
用户已批准执行推荐下一步。本任务提交 fedcaed1a13cc3b32ee47e1d72c2b5ffa5512c57(15 文件),前一笔详情修复 f9aee4c93272204d18408217358d95334cedcd5e(19 文件);两笔最终树 476364c76cf476e6af14f0f263599f4d5eced566 与原冻结包一致,origin/main 回读 fedcaed1,待推送 0。共享 QA 按补丁拆分,没有覆盖独立副本或混入根 checkout 改动。
135/135 单测、39/39 页面和真实隔离流程的输入未变,复用原结果,不声称本轮重跑。独立 reviewer 受 inline 限制仍待完成,没有继承旧候选审查或批准部署。生产只读预检确认仍 fe025e65、68 条迁移无 pending、41.09 GiB 余量、今日同版本备份完整性通过;当前镜像独立制品未找到,ARM 归档管理员凭据待补,配对恢复未做。
四名确认账号的门户/付款部门 gate 均开启;财务/yj 经理关系正确、本人所属主体仍缺,生产字段未回填。就绪与恢复详情复用 09-04/09-08/08-23 最新回执,不把资料存在性当银行解密或真实流程通过。联合副本 artifacts/qa-flow/20260911-combined/ 保存 commit-plan、commit-receipt、push-receipt;artifacts/qa-flow/20260911-readonly/ 保存新预检。历史 manifest/补丁未重写。
本节覆盖下文“未提交/未推送”的历史状态;回执为候选之外的未提交文档补记,任务不归档。下一步独立复核及另批当前镜像保存/配对恢复和就绪缺项;部署及公网合成验收仍另批,不让真人代测。
已获 2026-09-11 综合规划实施批准;独立工作树基线 fe025e65。复用已读 backend/frontend/权限规约;未授权生产操作、提交、推送或归档。 已获 2026-09-11 综合规划实施批准;独立工作树基线 fe025e65。复用已读 backend/frontend/权限规约;未授权生产操作、提交、推送或归档。
- [x] 只读事实加载与四类投影,最小 DTO 和正反例。 - [x] 只读事实加载与四类投影,最小 DTO 和正反例。
@@ -7,7 +82,7 @@
- [x] Vitest 服务/路由、客户端 typecheck/build、合成 Playwright、真实隔离业务状态演进验证。 - [x] Vitest 服务/路由、客户端 typecheck/build、合成 Playwright、真实隔离业务状态演进验证。
- [x] 主会话只读检查和规约更新;与详情修复按补丁形成联合工作树,不整文件覆盖。 - [x] 主会话只读检查和规约更新;与详情修复按补丁形成联合工作树,不整文件覆盖。
- [ ] 独立只读 review,inline 主会话自检不抵扣。 - [ ] 独立只读 review,inline 主会话自检不抵扣。
- [ ] 明确授权后分别提交推送、冻结新候选。 - [x] 明确授权后分别提交推送,候选 fedcaed1 已冻结并远端回读。
- [ ] 最新生产/备份只读预检、就绪/恢复、获批发布及公网受控验收,分别登记,不由本地 PASS 推导。 - [ ] 最新生产/备份只读预检、就绪/恢复、获批发布及公网受控验收,分别登记,不由本地 PASS 推导。
## 2026-09-11 实施与联合验收回执 ## 2026-09-11 实施与联合验收回执
@@ -11,6 +11,8 @@
- 财务看到未付款且未被有效批次占用的已审批报销;缺资料仍是待处理,不把待组批投影说成已通过创建门禁。部门功能开关与真实批次入口权限保持一致。 - 财务看到未付款且未被有效批次占用的已审批报销;缺资料仍是待处理,不把待组批投影说成已通过创建门禁。部门功能开关与真实批次入口权限保持一致。
- 经理/CEO 的付款复核待办排除本人制单;财务看到 reviewed/failed 批次的付款/回单登记待办,不把 draft 的复核动作交给制单人。 - 经理/CEO 的付款复核待办排除本人制单;财务看到 reviewed/failed 批次的付款/回单登记待办,不把 draft 的复核动作交给制单人。
- 门户 PC/手机消费同一服务端投影;每个对象/阶段稳定唯一,数量与列表一致,处理后重新查询即消失,不依赖通知送达或复制出持久化待办。 - 门户 PC/手机消费同一服务端投影;每个对象/阶段稳定唯一,数量与列表一致,处理后重新查询即消失,不依赖通知送达或复制出持久化待办。
- 手机首次进入也必须为非财务员工和独立复核人加载本人获授权的业务待办;不能沿用旧财务汇总的角色开关限制整个门户查询,也不能依赖先访问 PC 或已有缓存。财务卡片和写接口继续执行原权限边界。
- 用户已确认本地修复门户身份隔离:同一浏览器退出并登录另一账号时,PC/手机不能显示上一账号的待办;当前响应慢、失败或旧请求晚返回也不能跨身份复用。保留正常前缀失效刷新,不扩角色或修改全局认证。
- 不输出金额、事由、姓名、银行卡或原始错误。只展示业务编号、阶段、责任职责和有权限的目标入口。手机不新增批量付款能力。 - 不输出金额、事由、姓名、银行卡或原始错误。只展示业务编号、阶段、责任职责和有权限的目标入口。手机不新增批量付款能力。
## Scope ## Scope
@@ -31,8 +33,12 @@ Vitest 四类服务与 portal route;真实隔离 PostgreSQL/认证/HTTP 退回
- [x] 同一响应的 total 与实际 items 一致,无计数截断伪装;旧财务汇总不重复算作行动。 - [x] 同一响应的 total 与实际 items 一致,无计数截断伪装;旧财务汇总不重复算作行动。
- [x] 真实隔离库推进退回→重提→审批→组批→复核→登记,重复读取不写库,不依赖通知发送。 - [x] 真实隔离库推进退回→重提→审批→组批→复核→登记,重复读取不写库,不依赖通知发送。
- [x] PC/手机入口、错误与空态、查询刷新及最终联合工作树回归通过。 - [x] PC/手机入口、错误与空态、查询刷新及最终联合工作树回归通过。
- [ ] 独立只读 review、授权提交和部署后的受控验收,不以主会话本地测试抵扣。 - [x] 追加复核发现的手机非财务查询门修正;普通员工与单独 CEO 的冷进入、重读消失、错误重试及财务卡片不越权有浏览器回归。
- [x] 获批分笔提交推送,最终树与本地验证输入一致。
- [x] 追加身份隔离:双端同文档切账号的慢响应、失败重试、旧请求晚返回(含迟到 401 与甲→乙→甲)均不串显;旧身份请求取消,同身份正常刷新、既有冷进入和前缀刷新继续有效。
- [ ] 独立只读 review 和部署后的受控验收,不以主会话本地测试抵扣。
## Notes ## Notes
- 09-11 两轮主会话只读检查分别发现固定 key 串显、迟到 401 跨会话重试污染缓存;均在未修构建双端复现。沿原批准范围补身份 key 与请求取消,最新专项 10/10、联合 66/66、新构建及真实隔离业务链通过。原 6/62 和 NO_GO 证据保留;本地已测范围闭合不等于全局认证完成或生产已修复,独立复核与上线仍待完成。
- 复用 09-04/09-08 真实交接证据。本独立跨模块交付与详情一致性分工作树,不为每个页面/按钮新建 task。 - 复用 09-04/09-08 真实交接证据。本独立跨模块交付与详情一致性分工作树,不为每个页面/按钮新建 task。
@@ -15,12 +15,12 @@
"branch": null, "branch": null,
"base_branch": "main", "base_branch": "main",
"worktree_path": null, "worktree_path": null,
"commit": null, "commit": "fedcaed1a13cc3b32ee47e1d72c2b5ffa5512c57",
"pr_url": null, "pr_url": null,
"subtasks": [], "subtasks": [],
"children": [], "children": [],
"parent": null, "parent": null,
"relatedFiles": [], "relatedFiles": [],
"notes": "09-11本地实施完成,与09-04详情修复按补丁联合;135单测、39页面及真实隔离HTTP/双端状态演进通过,未调用真实银行/OCR或生产。基线fe025e65不含本次未提交改动;独立复核、授权提交推送、新鲜预检/配对恢复与部署验收待执行。证据与副本位置见implement,不归档。", "notes": "09-11复核dcde0d44发现旧请求迟到401污染旧身份缓存,双端甲→乙→甲已合成复现,原包NO_GO保留。沿原批准门户隔离范围接续取消:两页queryFn消费signal,经门户API传给HTTP,不改全局认证、角色或服务端。双端红测后最新专项10/10、联合66/66零重试、新构建/PWA及真实隔离业务链PASS,临时库/合成附件已清理;135单测按未变输入复用。新包postpush-cancellation,独立review仍PENDING_INLINE;需新输入交审、明确提交推送授权、新SHA执行时预检及另批部署/公网自动验收。本轮未提交推送、连接生产或部署,不声称全站认证缓存或真实过期会话已验证。",
"meta": {} "meta": {}
} }
+2 -2
查看文件
@@ -118,8 +118,8 @@ export type PortalHomePayload = {
teamStatusBoard: PortalTeamStatusItem[]; teamStatusBoard: PortalTeamStatusItem[];
}; };
export async function fetchPortalHome() { export async function fetchPortalHome(signal?: AbortSignal) {
return apiRequestData<PortalHomePayload>("/api/portal/home"); return apiRequestData<PortalHomePayload>("/api/portal/home", { signal });
} }
export async function fetchPortalTeamStatus() { export async function fetchPortalTeamStatus() {
+3 -3
查看文件
@@ -128,9 +128,9 @@ export function MobilePortalPage() {
}); });
const portalHomeQuery = useQuery({ const portalHomeQuery = useQuery({
enabled: canUseFinance, enabled: Boolean(me?.id),
queryKey: ["mobile", "portal", "home"], queryKey: ["mobile", "portal", "home", me?.id],
queryFn: () => fetchPortalHome(), queryFn: ({ signal }) => fetchPortalHome(signal),
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
+1 -1
查看文件
@@ -1073,7 +1073,7 @@ export function PaymentBatchesPage() {
<BusinessDetailAside> <BusinessDetailAside>
<BusinessDetailActionPanel <BusinessDetailActionPanel
className="lg:static" className="hidden lg:block lg:static"
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />} status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined} primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
moreActions={paymentActionModel.more.length ? ( moreActions={paymentActionModel.more.length ? (
+1 -1
查看文件
@@ -687,7 +687,7 @@ function ConditionalWorkPanel({
export function PortalHomePage() { export function PortalHomePage() {
const { me, permissions } = useAuth(); const { me, permissions } = useAuth();
const [showWelcome, setShowWelcome] = useState(false); const [showWelcome, setShowWelcome] = useState(false);
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome(), staleTime: 0, refetchOnMount: true, refetchOnWindowFocus: true }); const portalQuery = useQuery({ enabled: Boolean(me?.id), queryKey: ["portal-home", me?.id], queryFn: ({ signal }) => fetchPortalHome(signal), staleTime: 0, refetchOnMount: true, refetchOnWindowFocus: true });
const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]); const canSetupWorkspace = hasAnyPermission(permissions, ["system.settings"]);
useEffect(() => { useEffect(() => {
+3 -3
查看文件
@@ -1006,14 +1006,14 @@ export function ReimbursementDetailPage() {
} }
if (paymentHandoff?.targetHref) { if (paymentHandoff?.targetHref) {
return <Button asChild><Link to={paymentHandoff.targetHref}>查看所在付款批次</Link></Button>; return <Button asChild className="justify-self-start"><Link to={paymentHandoff.targetHref}>查看所在付款批次</Link></Button>;
} }
if (paymentHandoff?.canCreateBatch && canManagePaymentBatch) { if (paymentHandoff?.canCreateBatch && canManagePaymentBatch) {
return ( return (
<Button asChild data-testid="reimbursement-open-pending-payment-batches"> <Button asChild className="justify-self-start" data-testid="reimbursement-open-pending-payment-batches">
<Link to={`/payment-batches?view=pending&q=${encodeURIComponent(resolveReimbursementDisplayCode(detail))}`}> <Link to={`/payment-batches?view=pending&q=${encodeURIComponent(resolveReimbursementDisplayCode(detail))}`}>
去组建付款批次 创建批次
</Link> </Link>
</Button> </Button>
); );
@@ -676,9 +676,12 @@ test.describe("报销付款交接一致性", () => {
await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`); await page.goto(`${mobile ? "/mobile" : ""}/reimbursements/${reimbursementId}`);
const actions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel"); const actions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel");
await expect(actions).toContainText("已生成银行制单,等待付款完成并上传回单"); await expect(actions).toContainText("已生成银行制单,等待付款完成并上传回单");
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0); await expect(page.getByRole("link", { name: "创建批次", exact: true })).toHaveCount(0);
const link = page.getByRole("link", { name: "查看所在付款批次", exact: true }); const link = page.getByRole("link", { name: "查看所在付款批次", exact: true });
await expect(link).toHaveCount(1); await expect(link).toHaveCount(1);
const linkBounds = await link.boundingBox();
expect(linkBounds!.height).toBeGreaterThanOrEqual(44);
if (!mobile) expect(linkBounds!.width).toBeLessThan(180);
await link.click(); await link.click();
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible(); await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
await page.goBack(); await page.goBack();
@@ -695,6 +698,34 @@ test.describe("报销付款交接一致性", () => {
}); });
} }
for (const width of [1280, 1440]) {
test(`桌面${width}创建批次按钮短文案按内容宽度且只导航不组批`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 960 });
await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("none") });
let batchWrites = 0;
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/payment-batches" && request.method() === "POST") batchWrites += 1;
});
await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`);
const button = page.getByRole("link", { name: "创建批次", exact: true });
await expect(button).toBeVisible();
const actions = page.getByTestId("reimbursement-action-panel");
const buttonBounds = await button.boundingBox();
const actionBounds = await actions.boundingBox();
expect(buttonBounds!.width).toBeGreaterThanOrEqual(44);
expect(buttonBounds!.width).toBeLessThan(160);
expect(buttonBounds!.width).toBeLessThan(actionBounds!.width * 0.7);
expect(buttonBounds!.height).toBeGreaterThanOrEqual(44);
expect(Math.abs(buttonBounds!.x - actionBounds!.x)).toBeLessThan(2);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true);
await actions.locator("..").screenshot({ path: testInfo.outputPath("compact-payment-action.png") });
await button.click();
await expect(page).toHaveURL(/\/payment-batches\?view=pending&q=/);
expect(batchWrites).toBe(0);
});
}
for (const [status, expected] of [ for (const [status, expected] of [
["none", "待财务组建付款批次"], ["draft", "待非制单人复核付款"], ["none", "待财务组建付款批次"], ["draft", "待非制单人复核付款"],
["reviewed", "已生成银行制单"], ["failed", "付款登记失败"], ["reviewed", "已生成银行制单"], ["failed", "付款登记失败"],
@@ -709,6 +740,7 @@ test.describe("报销付款交接一致性", () => {
await page.goto(`/reimbursements/${reimbursementId}`); await page.goto(`/reimbursements/${reimbursementId}`);
await expect(page.getByTestId("reimbursement-action-panel")).toContainText(expected); await expect(page.getByTestId("reimbursement-action-panel")).toContainText(expected);
await expect(page.getByRole("link", { name: /付款批次/ })).toHaveCount(0); await expect(page.getByRole("link", { name: /付款批次/ })).toHaveCount(0);
await expect(page.getByTestId("reimbursement-open-pending-payment-batches")).toHaveCount(0);
}); });
} }
@@ -941,11 +973,17 @@ test.describe("R7E 全站高风险操作二次确认", () => {
const progress = page.getByTestId("payment-batch-processing-panel"); const progress = page.getByTestId("payment-batch-processing-panel");
await expect(progress).toBeVisible(); await expect(progress).toBeVisible();
expect(await progress.evaluate((element) => Boolean(element.previousElementSibling?.querySelector('[data-testid="payment-batch-action-panel"]')))).toBe(true); expect(await progress.evaluate((element) => Boolean(element.previousElementSibling?.querySelector('[data-testid="payment-batch-action-panel"]')))).toBe(true);
const actionBounds = await sideActions.boundingBox(); const actionBounds = await actions.boundingBox();
const progressBounds = await progress.boundingBox(); const progressBounds = await progress.boundingBox();
expect(actionBounds).not.toBeNull(); expect(actionBounds).not.toBeNull();
expect(progressBounds).not.toBeNull(); expect(progressBounds).not.toBeNull();
expect(progressBounds!.y).toBeGreaterThanOrEqual(actionBounds!.y + actionBounds!.height); if (mobile) {
await expect(sideActions).toBeHidden();
expect(await actions.evaluate((element) => getComputedStyle(element).position)).toBe("fixed");
expect(actionBounds!.height).toBeGreaterThanOrEqual(44);
} else {
expect(progressBounds!.y).toBeGreaterThanOrEqual(actionBounds!.y + actionBounds!.height);
}
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
await page.screenshot({ path: testInfo.outputPath("before-payment-order.png"), fullPage: true }); await page.screenshot({ path: testInfo.outputPath("before-payment-order.png"), fullPage: true });
await actions.getByTestId("payment-batch-action-downloadOrder").click(); await actions.getByTestId("payment-batch-action-downloadOrder").click();
@@ -1142,25 +1180,33 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await login(page, user); await login(page, user);
await page.goto(`/reimbursements/${reimbursementId}`); await page.goto(`/reimbursements/${reimbursementId}`);
const attachment = page.locator(".detail-evidence-card").filter({ hasText: "合成报销敏感附件.pdf" }); const attachment = page.getByTestId("approval-attachments").locator(":scope > div").filter({
has: page.getByRole("button", { name: "查看补充附件:合成报销敏感附件.pdf", exact: true }),
});
await expect(attachment).toBeVisible(); await expect(attachment).toBeVisible();
await attachment.getByRole("button", { name: "删除" }).click(); await attachment.getByRole("button", { name: "删除附件", exact: true }).click();
const dialog = page.getByTestId("attachment-delete-confirm-dialog"); const dialog = page.getByTestId("attachment-delete-confirm-dialog");
await expect(dialog).toContainText("合成报销敏感附件.pdf"); await expect(dialog).toContainText("合成报销敏感附件.pdf");
await expect(dialog).toContainText("无法在 OA 内恢复"); await expect(dialog).toContainText("无法在 OA 内恢复");
expect(calls.deleteReimbursementAttachment).toBe(0); expect(calls.deleteReimbursementAttachment).toBe(0);
await page.getByTestId("attachment-delete-confirm-submit").click();
await expect(dialog).toContainText("请填写至少 2 个字的删除理由");
expect(calls.deleteReimbursementAttachment).toBe(0);
await dialog.getByRole("textbox", { name: "删除理由" }).fill("一");
await page.getByTestId("attachment-delete-confirm-submit").click(); await page.getByTestId("attachment-delete-confirm-submit").click();
await expect(dialog).toContainText("请填写至少 2 个字的删除理由"); await expect(dialog).toContainText("请填写至少 2 个字的删除理由");
expect(calls.deleteReimbursementAttachment).toBe(0); expect(calls.deleteReimbursementAttachment).toBe(0);
await dialog.getByRole("button", { name: "取消" }).click(); await dialog.getByRole("button", { name: "取消" }).click();
await expect(dialog).not.toBeVisible();
expect(calls.deleteReimbursementAttachment).toBe(0); expect(calls.deleteReimbursementAttachment).toBe(0);
await attachment.getByRole("button", { name: "删除" }).click(); await attachment.getByRole("button", { name: "删除附件", exact: true }).click();
await page.getByRole("textbox", { name: "删除理由" }).fill("附件重复上传"); await page.getByRole("textbox", { name: "删除理由" }).fill("附件重复上传");
await page.getByTestId("attachment-delete-confirm-submit").click(); await page.getByTestId("attachment-delete-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursementAttachment).toBe(1); await expect.poll(() => calls.deleteReimbursementAttachment).toBe(1);
expect(calls.attachmentDeleteReasons).toEqual(["附件重复上传"]); expect(calls.attachmentDeleteReasons).toEqual(["附件重复上传"]);
await expect(dialog).not.toBeVisible();
}); });
test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => { test("付款回单作废要求对象、后果和理由,取消与理由不足均为零请求", async ({ page }) => {
+274 -15
查看文件
@@ -28,8 +28,10 @@ function envelope<T>(data: T) {
return { requestId: "visual-portal", data }; return { requestId: "visual-portal", data };
} }
function portalPayload(mode: "employee" | "admin" | "finance") { type PortalMode = "employee" | "admin" | "finance" | "ceo";
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利";
function portalPayload(mode: PortalMode) {
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : mode === "ceo" ? "合成复核人" : "王顺利";
const base = { const base = {
workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" }, workspace: { id: "workspace-visual", persisted: true, profile: "small_team_internal" },
greeting: { name: displayName }, greeting: { name: displayName },
@@ -112,7 +114,7 @@ function portalPayload(mode: "employee" | "admin" | "finance") {
}; };
} }
async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "finance", handoffs?: () => PortalHomePayload["businessHandoffs"]) { async function mockVisualPortal(page: Page, mode: PortalMode, handoffs?: () => PortalHomePayload["businessHandoffs"]) {
await page.addInitScript(() => { await page.addInitScript(() => {
window.localStorage.removeItem("hyb_oa_show_welcome"); window.localStorage.removeItem("hyb_oa_show_welcome");
window.localStorage.removeItem("hyb_oa_onboarding_active"); window.localStorage.removeItem("hyb_oa_onboarding_active");
@@ -139,8 +141,8 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
return; return;
} }
if (pathName === "/api/me") { if (pathName === "/api/me") {
const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : "王顺利"; const displayName = mode === "employee" ? "陈工程" : mode === "finance" ? "赵财务" : mode === "ceo" ? "合成复核人" : "王顺利";
const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : "HYD000006"; const employeeNo = mode === "employee" ? "HYD000003" : mode === "finance" ? "HYD000004" : mode === "ceo" ? "SYN-CEO" : "HYD000006";
await route.fulfill({ await route.fulfill({
status: 200, status: 200,
contentType: "application/json", contentType: "application/json",
@@ -149,7 +151,7 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
employeeNo, employeeNo,
name: displayName, name: displayName,
email: `${mode}@hyinda.com`, email: `${mode}@hyinda.com`,
roleCodes: mode === "employee" ? ["employee"] : mode === "finance" ? ["finance"] : ["admin"], roleCodes: [mode],
notificationPushEnabled: true, notificationPushEnabled: true,
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 }, profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
wecomBinding: null, wecomBinding: null,
@@ -194,6 +196,12 @@ async function mockVisualPortal(page: Page, mode: "employee" | "admin" | "financ
}); });
} }
function responseGate() {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
return { pending, release };
}
test.describe("visual portal", () => { test.describe("visual portal", () => {
for (const mobile of [false, true]) { for (const mobile of [false, true]) {
test(`业务交接${mobile ? "手机" : "PC"}显示真实事项并在处理后刷新消失`, async ({ page }, testInfo) => { test(`业务交接${mobile ? "手机" : "PC"}显示真实事项并在处理后刷新消失`, async ({ page }, testInfo) => {
@@ -221,15 +229,266 @@ test.describe("visual portal", () => {
}); });
} }
test("员工退回待修改直接进入本人的报销,不依赖通知未读数", async ({ page }) => { for (const role of ["employee", "ceo"] as const) {
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项"); for (const mobile of [false, true]) {
await mockVisualPortal(page, "employee", () => ({ total: 1, items: [{ key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }] })); test(`非财务${role}的${mobile ? "手机" : "PC"}冷进入加载本人待办并在处理后消失`, async ({ page }, testInfo) => {
await login(page, EMPLOYEE); test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
await page.goto("/portal"); const handoff: NonNullable<PortalHomePayload["businessHandoffs"]>["items"][number] = role === "employee"
const row = page.getByRole("link").filter({ hasText: "报销退回待修改" }); ? { key: "reimbursement-edit:synthetic", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/synthetic", mobileHref: "/mobile/reimbursements/synthetic" }
await expect(row).toHaveCount(1); : { key: "payment-review:synthetic", kind: "payment_review", reference: "PB-SYN", title: "付款批次待复核", nextStep: "由非制单人复核", ownerLabel: "非制单复核人", href: "/payment-batches?id=synthetic", mobileHref: "/mobile/payment-batches?id=synthetic" };
await expect(row).toHaveAttribute("href", "/reimbursements/synthetic"); let items = [handoff];
}); await mockVisualPortal(page, role, () => ({ total: items.length, items }));
await page.context().addCookies([{ name: "has_session", value: "1", url: String(testInfo.project.use.baseURL) }]);
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
await page.goto(mobile ? "/mobile" : "/portal");
const row = page.getByRole("link").filter({ hasText: handoff.title });
await expect(row).toHaveCount(1);
await expect(row).toHaveAttribute("href", mobile ? handoff.mobileHref : handoff.href);
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
await expect(page.getByRole("link").filter({ hasText: "报销待组建付款批次" })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("nonfinance-handoffs.png"), fullPage: true });
items = [];
const refreshed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
await page.reload();
await refreshed;
await expect(row).toHaveCount(0);
await expect(page.getByTestId("mobile-business-handoffs")).toHaveCount(0);
});
}
test(`非财务${role}手机待办加载失败可重试,不把错误当空态`, async ({ page }, testInfo) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
const handoff: NonNullable<PortalHomePayload["businessHandoffs"]>["items"][number] = role === "employee"
? { key: "reimbursement-edit:retry", kind: "reimbursement_edit", reference: "BX-SYN", title: "报销退回待修改", nextStep: "修改后重新提交", ownerLabel: "报销人", href: "/reimbursements/retry", mobileHref: "/mobile/reimbursements/retry" }
: { key: "payment-review:retry", kind: "payment_review", reference: "PB-SYN", title: "付款批次待复核", nextStep: "由非制单人复核", ownerLabel: "非制单复核人", href: "/payment-batches?id=retry", mobileHref: "/mobile/payment-batches?id=retry" };
await mockVisualPortal(page, role, () => ({ total: 1, items: [handoff] }));
let unavailable = true;
await page.route("**/api/portal/home", async (route) => {
if (unavailable) await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "synthetic unavailable" } }) });
else await route.fallback();
});
await page.context().addCookies([{ name: "has_session", value: "1", url: String(testInfo.project.use.baseURL) }]);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mobile");
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toBeVisible();
await expect(page.getByTestId("mobile-business-handoffs")).toHaveCount(0);
unavailable = false;
await page.getByRole("button", { name: "重试加载待办", exact: true }).click();
await expect(page.getByRole("link").filter({ hasText: handoff.title })).toBeVisible();
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
});
}
for (const mobile of [false, true]) {
for (const scenario of ["slow", "failure", "late", "late401"] as const) {
test(`门户身份隔离 ${mobile ? "手机" : "PC"} 同文档切账号 ${scenario}`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成账号,不访问公网");
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
let actor: "first" | "second" = "first";
let holdOldRequest = false;
let oldRequestStarted = false;
let oldRequestFinished = false;
let oldRequestAborted = false;
let holdFirstReturn = false;
let firstReturnStarted = false;
let refreshRequests = 0;
let secondFails = scenario === "failure";
const requestCounts = { first: 0, second: 0 };
const secondResponse = responseGate();
const oldResponse = responseGate();
const firstReturnResponse = responseGate();
const handoff = (identity: "first" | "second") => ({
key: `reimbursement-edit:synthetic-cache-${identity}`, kind: "reimbursement_edit" as const,
reference: `BX-SYN-CACHE-${identity}`, title: identity === "first" ? "合成甲待办" : "合成乙待办",
nextStep: "修改后重新提交", ownerLabel: "报销人",
href: `/reimbursements/synthetic-cache-${identity}`, mobileHref: `/mobile/reimbursements/synthetic-cache-${identity}`,
});
await mockVisualPortal(page, "employee");
const requestActor = (authorization?: string) => authorization === "Bearer synthetic-first" ? "first" : "second";
page.on("requestfailed", (request) => {
if (oldRequestStarted && new URL(request.url()).pathname === "/api/portal/home" && requestActor(request.headers().authorization) === "first") oldRequestAborted = true;
});
await page.route("**/api/auth/login", async (route) => {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: `synthetic-${actor}`, expiresIn: 900, mustChangePassword: false })) });
});
await page.route("**/api/auth/refresh", async (route) => {
refreshRequests += 1;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: `synthetic-${actor}`, expiresIn: 900, mustChangePassword: false })) });
});
await page.route("**/api/me", async (route) => {
const profileActor = requestActor(route.request().headers().authorization);
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({
id: `synthetic-cache-${profileActor}`, employeeNo: `SYN-${profileActor}`, name: profileActor === "first" ? "合成员工甲" : "合成员工乙",
email: `${profileActor}@example.test`, roleCodes: ["employee"], notificationPushEnabled: true, wecomBinding: null,
profileCompleteness: { hasEmergencyContact: true, hasBankAccount: true, hasAvatar: false, hasIdNumber: false, score: 50 },
})) });
});
await page.route("**/api/portal/home", async (route) => {
const requestedActor = requestActor(route.request().headers().authorization);
requestCounts[requestedActor] += 1;
const delayedOldRequest = requestedActor === "first" && holdOldRequest;
if (delayedOldRequest) {
oldRequestStarted = true;
await oldResponse.pending;
if (scenario === "late401") {
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_002" } }) });
oldRequestFinished = true;
return;
}
}
if (requestedActor === "first" && holdFirstReturn) {
firstReturnStarted = true;
await firstReturnResponse.pending;
}
if (requestedActor === "second") await secondResponse.pending;
if (requestedActor === "second" && secondFails) {
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: { code: "INTERNAL_ERROR", message: "synthetic unavailable" } }) });
} else {
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({
...portalPayload("employee"), businessHandoffs: { total: 1, items: [handoff(requestedActor)] },
pendingTasks: { count: 0, href: "/workflow/tasks" }, todoSummary: null, weeklyReport: null, projectSummary: null,
})) });
}
if (delayedOldRequest) oldRequestFinished = true;
});
try {
await page.goto(`/login?redirect=${mobile ? "/mobile" : "/portal"}`);
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly123!");
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(mobile ? /\/mobile$/ : /\/portal$/);
const firstRow = page.getByRole("link").filter({ hasText: handoff("first").title });
const secondRow = page.getByRole("link").filter({ hasText: handoff("second").title });
await expect(firstRow).toBeVisible();
await page.evaluate(() => { Reflect.set(window, "__portalSwitchDocument", "same-document"); });
if (scenario === "late" || scenario === "late401") {
holdOldRequest = true;
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.poll(() => oldRequestStarted).toBe(true);
}
if (mobile) {
await page.locator('a[href="/mobile/me"]').last().click();
await page.getByRole("button", { name: "退出登录", exact: true }).click();
} else {
await page.getByTestId("topbar-profile-trigger").first().click();
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
}
await expect(page).toHaveURL(/\/login/);
actor = "second";
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly123!");
await page.getByTestId("login-submit").click();
if (mobile) {
await expect(page.getByTestId("mobile-me-profile-name")).toHaveText("合成员工乙");
await page.locator('a[href="/mobile"]').last().click();
} else {
await expect(page.getByTestId("topbar-profile-trigger").first()).toContainText("合成员工乙");
}
await expect.poll(() => requestCounts.second).toBeGreaterThan(0);
expect(await page.evaluate(() => Reflect.get(window, "__portalSwitchDocument"))).toBe("same-document");
await expect(firstRow).toHaveCount(0, { timeout: 5000 });
await expect(secondRow).toHaveCount(0);
secondResponse.release();
if (scenario === "failure") {
const retry = mobile ? page.getByRole("button", { name: "重试加载待办", exact: true }) : page.getByTestId("portal-load-retry");
await expect(retry).toBeVisible();
await expect(firstRow).toHaveCount(0);
secondFails = false;
await retry.click();
}
await expect(secondRow).toBeVisible();
if (scenario === "late" || scenario === "late401") {
if (scenario === "late401") await expect.poll(() => oldRequestAborted, { timeout: 5000 }).toBe(true);
const refreshRequestsBeforeOldResponse = refreshRequests;
oldResponse.release();
await expect.poll(() => oldRequestFinished).toBe(true);
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
if (scenario === "late401") expect(refreshRequests).toBe(refreshRequestsBeforeOldResponse);
}
await expect(firstRow).toHaveCount(0);
await expect(secondRow).toHaveCount(1);
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
if (scenario === "late401") {
if (mobile) {
await page.locator('a[href="/mobile/me"]').last().click();
await page.getByRole("button", { name: "退出登录", exact: true }).click();
} else {
await page.getByTestId("topbar-profile-trigger").first().click();
await page.getByRole("menuitem", { name: "退出登录", exact: true }).click();
}
await expect(page).toHaveURL(/\/login/);
actor = "first";
holdOldRequest = false;
holdFirstReturn = true;
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly123!");
await page.getByTestId("login-submit").click();
if (mobile) {
await expect(page.getByTestId("mobile-me-profile-name")).toHaveText("合成员工甲");
await page.locator('a[href="/mobile"]').last().click();
} else {
await expect(page.getByTestId("topbar-profile-trigger").first()).toContainText("合成员工甲");
}
await expect.poll(() => firstReturnStarted).toBe(true);
expect(await page.evaluate(() => Reflect.get(window, "__portalSwitchDocument"))).toBe("same-document");
await expect(secondRow).toHaveCount(0);
firstReturnResponse.release();
await expect(firstRow).toBeVisible();
await expect(secondRow).toHaveCount(0);
}
} finally {
secondResponse.release();
oldResponse.release();
firstReturnResponse.release();
await page.unrouteAll({ behavior: "wait" });
}
});
}
}
for (const mobile of [false, true]) {
test(`门户身份隔离 ${mobile ? "手机" : "PC"} 同身份401刷新仍可用`, async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成认证,不访问公网");
await page.setViewportSize(mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
let portalRequests = 0;
let refreshRequests = 0;
await mockVisualPortal(page, "employee", () => ({ total: 0, items: [] }));
await page.route("**/api/auth/refresh", async (route) => {
refreshRequests += 1;
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(envelope({ accessToken: "visual-token", expiresIn: 900 })) });
});
await page.route("**/api/portal/home", async (route) => {
portalRequests += 1;
if (portalRequests === 1) {
await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: { code: "AUTH_002" } }) });
} else {
await route.fallback();
}
});
await page.goto(`/login?redirect=${mobile ? "/mobile" : "/portal"}`);
await page.getByTestId("login-identifier").fill("[email protected]");
await page.getByTestId("login-password").fill("SyntheticOnly123!");
const loaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home" && response.status() === 200);
await page.getByTestId("login-submit").click();
await expect(page).toHaveURL(mobile ? /\/mobile$/ : /\/portal$/);
expect((await (await loaded).json()).data.businessHandoffs.total).toBe(0);
await expect.poll(() => portalRequests).toBe(2);
expect(refreshRequests).toBe(1);
if (mobile) {
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
await expect(page.getByText("业务待办加载失败,请重试。", { exact: true })).toHaveCount(0);
} else {
await expect(page.getByTestId("portal-work-summary")).toBeVisible();
await expect(page.getByTestId("portal-load-error")).toHaveCount(0);
}
});
}
test("超过八条业务事项时计数与展示不截断", async ({ page }) => { test("超过八条业务事项时计数与展示不截断", async ({ page }) => {
test.skip(process.env.E2E_MODE === "live", "仅本地合成事项"); test.skip(process.env.E2E_MODE === "live", "仅本地合成事项");
+76 -1
查看文件
@@ -85,7 +85,10 @@ async function main() {
await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" }); await app.register(serverRequire("./src/routes/auth"), { prefix: "/api/auth" });
app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); app.setErrorHandler((error, request, reply) => reply.status(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } }));
const envelope = (data) => ({ requestId: "synthetic-http", data }); const envelope = (data) => ({ requestId: "synthetic-http", data });
app.get("/api/me", { preHandler: app.authenticate }, async (request) => envelope({ id: request.employee.id, employeeNo: request.employee.employee_no, name: "synthetic", email: request.employee.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: request.employee.roleCodes, notificationPushEnabled: false })); app.get("/api/me", { preHandler: app.authenticate }, async (request) => {
const profile = await prisma.employee.findUniqueOrThrow({ where: { id: request.employee.id }, select: { employee_no: true, email: true } });
return envelope({ id: request.employee.id, employeeNo: profile.employee_no, name: "synthetic", email: profile.email, departmentId: department.id, directManagerId: manager.id, status: "active", roleCodes: request.employee.roleCodes, notificationPushEnabled: false });
});
app.get("/api/system/navigation", async () => envelope({ workspace: { id: "synthetic", profile: "small_team_internal", orgMode: "single_company", moduleVisibility: {} }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [] })); app.get("/api/system/navigation", async () => envelope({ workspace: { id: "synthetic", profile: "small_team_internal", orgMode: "single_company", moduleVisibility: {} }, sections: { main: [], admin: [], backup: [] }, recommendedRolePresets: [] }));
app.get("/api/notifications", async () => envelope({ items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 })); app.get("/api/notifications", async () => envelope({ items: [], total: 0, unreadCount: 0, page: 1, pageSize: 20 }));
await app.register(serverRequire("./src/routes/expenseTypes"), { prefix: "/api" }); await app.register(serverRequire("./src/routes/expenseTypes"), { prefix: "/api" });
@@ -134,6 +137,78 @@ async function main() {
}; };
assert.deepEqual(await getHandoffs(), await getHandoffs()); assert.deepEqual(await getHandoffs(), await getHandoffs());
assert.equal(await prisma.auditLog.count(), auditCount, "handoff reads do not create audit or workflow writes"); assert.equal(await prisma.auditLog.count(), auditCount, "handoff reads do not create audit or workflow writes");
if (browser && preview && ["reimbursement_edit", "payment_review"].includes(kind)
&& [employee.id, approvers.ceo.id].includes(actor.id)) {
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}`;
const context = await browser.newContext({ serviceWorkers: "block", viewport: { width: 390, height: 844 } });
try {
const fingerprint = `synthetic-handoff-${randomUUID()}`;
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: actor.email, password: loginPassword },
});
assert.equal(login.status(), 200, "non-finance mobile real password login");
const page = await context.newPage();
page.setDefaultTimeout(20000);
const loaded = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
await page.goto(`${uiOrigin}/mobile`);
assert.equal((await loaded).status(), 200, "non-finance mobile cold entry actually loads handoffs");
const href = kind === "reimbursement_edit" ? `/mobile/reimbursements/${entityId}` : `/mobile/payment-batches?id=${entityId}`;
const row = page.getByTestId("mobile-business-handoffs").locator(`a[href="${href}"]`);
await expect(row).toHaveCount(expectedCount);
if (expectedCount) await expect(row).toBeVisible();
await expect(page.getByTestId("mobile-finance-summary")).toHaveCount(0);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2), true);
passed.push(`handoffs: non-finance mobile cold entry with real auth/HTTP ${kind} count=${expectedCount}, no finance card`);
if (kind === "reimbursement_edit" && expectedCount === 1 && actor.id === employee.id) {
await page.evaluate(() => { Reflect.set(window, "__realHandoffSwitchDocument", "same-document"); });
await page.locator('a[href="/mobile/me"]').last().click();
await page.getByRole("button", { name: "退出登录", exact: true }).click();
await expect(page).toHaveURL(/\/login/);
await page.getByTestId("login-identifier").fill(approvers.ceo.email);
await page.getByTestId("login-password").fill(loginPassword);
const secondLogin = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/auth/login");
const secondProfile = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/me");
await page.getByTestId("login-submit").click();
assert.equal((await secondLogin).status(), 200, "second actor UI password login");
const profileResponse = await secondProfile;
assert.equal(profileResponse.status(), 200, "second actor profile loaded");
assert.equal((await profileResponse.json()).data.id === approvers.ceo.id, true, "second actor identity matches");
await expect(page).toHaveURL(/\/mobile\/me$/);
await expect(page.getByTestId("mobile-me-profile-copy")).toContainText(approvers.ceo.employee_no);
let releaseResponse;
let secondActorResponseVerified = false;
const responseGate = new Promise((resolve) => { releaseResponse = resolve; });
await page.route("**/api/portal/home", async (route) => {
const response = await route.fetch();
assert.equal(response.status(), 200, "second actor real portal response");
const data = (await response.json()).data.businessHandoffs;
assert.equal(data.items.some((item) => item.href === `/reimbursements/${entityId}`), false);
secondActorResponseVerified = true;
await responseGate;
await route.fulfill({ response });
});
try {
await page.locator('a[href="/mobile"]').last().click();
await expect.poll(() => secondActorResponseVerified).toBe(true);
assert.equal(await page.evaluate(() => Reflect.get(window, "__realHandoffSwitchDocument")), "same-document");
await expect(row).toHaveCount(0);
const refreshed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/portal/home");
releaseResponse();
await refreshed;
await expect(row).toHaveCount(0);
passed.push("handoffs: same-document employee-to-CEO mobile switch with real password login and isolated HTTP, no former actor handoff while response is held");
} finally {
releaseResponse();
await page.unrouteAll({ behavior: "wait" });
}
}
} finally {
await context.close();
}
}
}; };
const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor); const bind = (invoice, form, actor) => post(`/invoice-pool/${invoice.id}/bind`, { reimbursementId: form.id, createItem: true }, actor);
const objects = [ const objects = [
+5 -2
查看文件
@@ -96,9 +96,12 @@ module.exports = async function verifyPaymentExperience({
await page.goto(`${uiOrigin}${mobile ? "/mobile" : ""}/reimbursements/${form.id}`); await page.goto(`${uiOrigin}${mobile ? "/mobile" : ""}/reimbursements/${form.id}`);
const sourceActions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel"); const sourceActions = page.getByTestId(mobile ? "mobile-reimbursement-payment-actions" : "reimbursement-action-panel");
await expect(sourceActions).toContainText("已生成银行制单"); await expect(sourceActions).toContainText("已生成银行制单");
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0); await expect(page.getByRole("link", { name: "创建批次", exact: true })).toHaveCount(0);
const sourceLink = page.getByRole("link", { name: "查看所在付款批次", exact: true }); const sourceLink = page.getByRole("link", { name: "查看所在付款批次", exact: true });
await expect(sourceLink).toHaveCount(1); await expect(sourceLink).toHaveCount(1);
const linkBounds = await sourceLink.boundingBox();
assert.ok(linkBounds.height >= 44);
if (!mobile) assert.ok(linkBounds.width < 180);
await sourceLink.click(); await sourceLink.click();
const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel"); const actions = page.getByTestId(mobile ? "payment-batch-mobile-actions" : "payment-batch-action-panel");
await expect(actions.getByRole("button", { name: "上传银行回单" })).toBeVisible(); await expect(actions.getByRole("button", { name: "上传银行回单" })).toBeVisible();
@@ -117,7 +120,7 @@ module.exports = async function verifyPaymentExperience({
await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible(); await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible();
await page.goBack(); await page.goBack();
await expect(sourceActions).toContainText("已生成银行制单"); await expect(sourceActions).toContainText("已生成银行制单");
await expect(page.getByText("去组建付款批次", { exact: true })).toHaveCount(0); await expect(page.getByRole("link", { name: "创建批次", exact: true })).toHaveCount(0);
await expect(sourceLink).toHaveCount(1); await expect(sourceLink).toHaveCount(1);
const sourceProgress = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"); const sourceProgress = page.getByTestId(mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress");
await expect(sourceProgress).toHaveCount(1); await expect(sourceProgress).toHaveCount(1);