修复授权分页并补齐追回到账诊断验证(09-17-company-loan-principal-repayment)
这个提交包含在:
@@ -1155,6 +1155,7 @@ await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
|
||||
- Same-company employee association includes archived/history rows in duplicate checks, including the old identity-confirmation entry. Reuse a unique matching lender; reject ambiguous links and require explicit refresh for different data. Existing-lender actions lock lender before sorted employees. New creation never locks an existing lender after employees: finish and reacquire in the correct order if a link appeared. Use employee `FOR NO KEY UPDATE` to serialize association/source edits while allowing audit/actor FK `KEY SHARE`; `FOR UPDATE` can deadlock against another employee's refresh/audit. Persist and serialize each actor's request-key binding for both creation and reuse, with audit in the same transaction.
|
||||
- Explicit employee refresh rejects reserved repayments, checks source/identity/account versions and advances both lender versions with renewed finance identity confirmation. This invalidates old company-representative grants without changing principal or historical payment snapshots. Employee-linked lenders cannot use the manual account PATCH; employee-master edits alone never rewrite the loan copy. Verify the actual audit-FK interleaving, duplicate/legacy races, refresh versus repayment creation, audit rollback and reverse mutations using `RUN_ISOLATED_LOAN_EMPLOYEE_ONLY=1` and optional `RUN_ISOLATED_LOAN_EMPLOYEE_MUTATIONS=1` on the isolated loan runner; retain the original full-loan regression.
|
||||
- Related-party loans use the explicit `loanApprovalPolicy` branch, not a global reviewer exception. Finance proposes `LoanRelatedAuthorization` for one company/lender, its verified employee identity and specific confirmed loans; that designated active manager/CEO confirms it. Grants, scope and lifecycle events are append-only. Resolve the latest confirmed version without falling back to an older grant when the new scope is narrower or stopped; a superseded version cannot receive stop events.
|
||||
- The authorization selector calls `GET /loan-lenders?eligible=related_authority` under the existing finance/`loan.manage` gate. Filter verified, employee-linked, non-self lenders before both pagination and count; this selector filter does not replace final representative-role/scope checks. Omitted `eligible` preserves the ordinary list, unsupported values reject, and opening the dialog resets to page 1. Browser pagination waits for a loaded, changed option list and checks the intended IDs, including cache hits; a network response alone is insufficient. After evidence upload, select the exact returned evidence ID when its option is attached, never the last option or placeholder. Verify genuine page 3 with desktop/mobile scope registration and designated confirmation, an unchanged-page counterexample, and distinct synthetic payer/payee accounts for direction refusals. Set `RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC` to `authorization-pagination` or `refund` with the existing isolated DB/browser switches; diagnostic checks do not confer release eligibility.
|
||||
- Freeze `LoanRepayment.approval_mode`, `authorization_id` and `approval_context` at creation. Only `related_party` waives the designated payee-conflict reason. Keep maker/submitter/exporter/uploader/manual-entry/proposer conflicts as a separate union of all history, including voided and superseded materials; do not remove the payee employee from that union. Reject designated-reviewer participation at maker entry points as well as decision/final-consumer checks. Old independent loans and shared reimbursement/travel rules do not switch modes.
|
||||
- New payment creation/approval/export checks current payment authority; new reconciliation decisions and final reservation release check current reconciliation authority. `decisionFields()` stores purpose, actual actor, target, scope, authorization version/event and conflict identities. `assertDecision()` validates immutable historical provenance at receipt reconstruction, settlement, terminal/recovery consumption and bank-event booking; it does not require the old reviewer to remain active or the old grant to remain enabled. Stopping payment and stopping reconciliation are separate events. Previously approved bank facts remain usable if their evidence and all money/transaction checks still hold.
|
||||
- Related payment review may return an application with `decision=reject` and a reason. This records an immutable decision without releasing reservations; it cannot later be approved, exported or paid. Finance uses safe never-sent cancellation and creates a corrected application. The UI, responsibility and notification consumers must distinguish this from an approved payment or an independent review.
|
||||
|
||||
@@ -1,3 +1,43 @@
|
||||
## 2026-10-10 D:浏览器遗留失败排查设计(已批准实施、产品复核及本地补验完成,未提交)
|
||||
|
||||
沿当前借款任务 AC46–AC51,先补最小可观察实验,再定位并修复;不预判两项都是测试脚本问题。没有迁移、生产连接或新的业务规则。
|
||||
|
||||
最终产品行为:授权出借人列表使用可选 `eligible=related_authority`,在服务端分页和计数前排除未核实、未关联员工及财务本人;未传参数的原列表保持原行为,无效取值拒绝。授权弹窗每次打开重置出借人页码为 1。追回上传仍先关闭弹窗、再刷新;等待刷新后才关闭的试验已撤回,刷新期间禁用选项列为 P3,未实现。下面保留获批的实验合同,实际完成与证据以 implement 顶部为准。
|
||||
|
||||
提交前复核发现第三页诊断原先只验证选项,没有直接在第三页完成授权;追回旧策略诊断原先只记录选项,没有实际提交;资金回滚原例仅测晚到成功。按原 AC46/AC49/AC50 补实际第三页经办/本人确认、原生验证零 POST、精确 POST 的拒绝/pending/200 后刷新门闩,以及独立和关联两模式的追回金额、交易重复、审计失败回滚。补验不修改产品代码。
|
||||
|
||||
### D0:采证与定向运行
|
||||
|
||||
- 复用 `test-company-loan-db.cjs` 的隔离数据库、真实 Fastify/鉴权、合成 PDF 和真实浏览器。增加仅测试使用的可选 `RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC=authorization-pagination|refund` 定向入口,严格校验值并要求已有 DB/browser 开关;输出明确标为定向范围,不能冒称完整回归。现有员工专项入口保留。
|
||||
- 白名单事件时间线:设备、阶段、相对时间、角色别名、接口路径与必要页码/类别、请求开始/结束/取消、HTTP 状态、requestId、安全 reasonCode、按钮 pending/disabled、已显示页的合成 ID 和证据 ID。禁止 token/cookie、原始请求响应 payload、账号、真实原件及完整本地路径。表单采证仅记录有效性和字段是否存在,不保存账号值。
|
||||
- 监听在动作之前安装,退出/失败时统一收拢 Promise、移除监听并清理测试自有资源。截图和限定环节的浏览器 trace 只用合成页面;保留失败证据,控制产物大小。原 r6/r7、full-loan-r1 证据不覆盖不删除。
|
||||
- 同步区分浏览器原生验证无 POST、网络 pending/失败、服务端拒绝、提交成功但界面刷新失败;200 不能单独证明流程完成,DOM 关闭也不能单独证明写入成功。
|
||||
|
||||
### D1:授权翻页实验
|
||||
|
||||
受影响入口:`company-loan-employee-fixture.cjs` / `company-loan-employee-browser.cjs`,必要时 `LoanRelatedAuthorizations.tsx` 及 `/loan-lenders` 查询;保留每页 30 条、姓名/ID 排序与现有授权过滤。
|
||||
|
||||
1. 隔离 fixture 固定建立至少 65 名活跃、身份核实、员工关联且非财务本人的出借人,用稳定的合成姓名排序;通过实际列表 API 校验第二/第三页目标位置。位置不满足即 fixture 失败,不能跳过翻页或依赖其他测试多建的资料。
|
||||
2. 电脑/手机执行第一页→第二页→第三页→第二页→第三页,明确断言各页真实选项和目标 ID;选择目标借款、登记授权、本人确认,验证授权关联与审计、零还款创建。旧 `open()` 没有重置 lenderPage 的行为仅作为待观察条件,不预先增加“每次回第一页”的产品要求。
|
||||
3. 使用可控路由门闩而非任意 sleep:首次响应正常/快速、第二页响应延迟、无关同路径的其他页响应先到、5 秒有效期内返回缓存页、关闭重开。缓存场景以选项正确到达为结果,不强制等待不存在的新 GET;需要等待响应时精确识别目标 page/pageSize 与 actor,并继续检查渲染。
|
||||
4. 控制实验让页码不变化或返回错误页,证明测试确实抓到停在旧页。还原旧等待方式时从保留版本/差异取证,若不能逐字还原则注明限制;人为注入的等待失败不是原事故根因证据。仅在同条件能复现历史失败并通过单一修复消除后,才归因。
|
||||
|
||||
### D2:追回到账实验
|
||||
|
||||
受影响入口:`company-loan-reconciliation-browser.cjs`、`company-loan-related-browser.cjs`、相应 HTTP fixture;必要时 `LoanBankReconciliation.tsx`、`paymentBatches.js` / `loanBankResults.js`。复用既有退款证据类别 `incoming_return`、相反账户方向、booked 的晚到成功父事件、金额/归属/声明/审计和银行交易唯一保护。
|
||||
|
||||
1. 为每台设备和审批模式各建独立场景,不用一次场景的成功代替另一模式。保留原始独立模式失败链,当前关联模式由不同身份的财务/公司代表分别办理。
|
||||
2. 先产生已登记晚到成功和 40 元待追回,再分别上传合成退款证据、追回 15 和 25 元。上传响应绑定本批次 POST;保存返回的 evidenceId,等待该 ID 出现在实际 options,断言选中后才提交;用旧“最后一个 option”策略与延迟刷新作对照,不假定这一竞争就是旧事故原因。
|
||||
3. 对每次登记精确观察 `/payment-batches/:id/loan-bank-results` POST;检查 returned event 的 parent/evidence/金额,再由另一人复核和财务入账。200 后分别等待对应列表刷新与状态显示,读取数据库确认待追回金额、交易占用、冻结原因与原借款/原终态/批次数;不把等待弹窗消失作为 HTTP 请求的替代品。
|
||||
4. 小矩阵覆盖:提交时无有效证据、上传完成但列表读取延迟、登记 POST 延迟、200 后刷新延迟、有效可见的拒绝信息,以及实际事务内审计失败回滚。既有 HTTP 反例优先复用;补本次遗漏而非复制整套资金守卫。
|
||||
5. 重试按既有请求键/银行交易身份合同检验:相同键同内容幂等,新的声明可按既有取代规则保留历史,但同一交易不能重复入账;部分追回不解除其他原因冻结,不能改成直接消除本金或生成退款付款批次。
|
||||
|
||||
### 修复、验证与兼容边界
|
||||
|
||||
采证先定位层次;纯等待/fixture 问题只改测试。已证实的界面或接口缺陷才做对应最小改动,不扩展通用组件、不统一放大超时、不加盲目重试。触及权限、状态消费、资金保护时补只读独立 review;若需要改既定合同或新增迁移,返回方案确认。
|
||||
|
||||
最终分别重验强制触发的两项场景(每设备连续 3 轮),再一次完整借款回归及受影响层检查;未改业务代码时无需为测试变化重复前端构建。提交/发布另行授权,测试修复不自动部署。原事故无法复现时可交付采证与稳定分支覆盖,但结论仍为原根因未定,不凭多轮绿灯结案。
|
||||
|
||||
## 2026-10-10 N5-C 出借人在线确认设计
|
||||
|
||||
1. 新增专用声明、借款快照及单次决定三张表,记录固定的公司/出借人/关联员工、财务发起人、请求键、日期、金额、原件 ID/摘要和版本。声明与范围在一个事务内完整成立,数据库约束保障范围一致及历史不可改删;失效只允许从有效到无效,保留原决定。
|
||||
|
||||
@@ -1,3 +1,98 @@
|
||||
## 2026-10-10 D:本地提交收尾(用户已明确授权)
|
||||
|
||||
用户已明确授权本次本地提交,范围为两项遗留失败的测试修复与诊断补验、授权列表的分页前筛选及弹窗重开重置,以及本任务记录和既有 backend spec,共 14 个文件。沿用下节已完成的业务验证,提交前补做最终文档、治理、敏感内容和暂存差异检查;具体提交结果以 Git 回执为准。
|
||||
|
||||
本次授权不包含推送、部署、正式发布门禁或归档。本轮没有新迁移,产品改动可并入下次发版;父任务保持 `in_progress`,N5/N6 真实办理、暂停的登记默认值和 P3 上传后刷新体验继续保留。下节“未提交”等表述记录提交前验证时点。
|
||||
|
||||
## 2026-10-10 D:提交前复核与补验完成(Codex 会话,提交前验证时点记录)
|
||||
|
||||
在 Claude 实施结果上继续收尾。官方 current 与执行工作区一致,基线 `db919dc6`;未提交、推送、部署或连接生产。产品三文件只读交叉复核:原鉴权保留、可选筛选先于分页且列表/计数共用条件、原无筛选调用兼容、打开时重置页码;未发现产品阻断项。本会话仅补测试、task 文档和既有 backend spec 的稳定规则,新增测试属于主会话自查,不冒称另一个 reviewer 独立重跑。
|
||||
|
||||
复核核实原三轮日志各为 12 阶段、去重 224 项,但发现三个验收证据缺口:第三页诊断没有实际登记/本人确认;旧追回选择诊断只记录占位项,没有提交证明零 POST;已有审计失败针对晚到成功,不能替代追回回滚。按原 AC46/AC49/AC50 补齐,不放宽验收,也不改产品范围。原记录在下节保留实施时点。
|
||||
|
||||
- D1:在明确 65 名可选出借人之外补本人/未核实排除样本;精确比较各页,真实第三页登记、本人确认、范围/审计及零还款断言。`review-pagination-r2/r3/r4.log` 连续三轮电脑/手机全部通过,包含实际缓存命中和不变页面反例。`review-pagination-r1.log` 因新增诊断误点公司代表页面不存在的财务标签而失败,已删该多余动作,日志和 finance trace 保留;该次代表 context 没有 trace,不宣称它已采到代表页面失败。
|
||||
- D2:改用可释放的刷新/请求门闩,实际填旧占位项并点击、验证只有证据选择无效且零 POST;分别核对实际 409、pending、200 后刷新及绑定的 event/parent/evidence。电脑/手机各五类诊断全部通过,诊断 context 保留合成 trace。资金补验在独立和关联模式共用同一断言,各 10 组通过,覆盖方向/类别/作废/剩余额度、请求键、银行交易、复核拒绝、真实 `LOAN_RETURN_BOOKED` 审计失败和其他冻结原因保留。AC50 的本轮证据缺口已关闭;不是依赖晚到成功的回滚例。
|
||||
- `review-refund-full-r1.log` 在取消后尝试作废未消费材料,被已有 `loan.payment_not_ready` 正确拒绝,未走到资金断言;改为取消前经实际接口作废,未据此改产品的作废规则。
|
||||
- `review-refund-full-r2.log` 的独立/关联各 10 组金额保护已通过,电脑旧策略的原生验证明确零 POST。但错误方向探针得到 200,检查发现旧追回 UI 合成素材的公司账号与出借人账号相同,交换后没有改变输入。仅将该 UI 素材的双方账号分开,不降低 409 断言;失败日志及合成 trace 保留。未把 r2 报为整轮通过。
|
||||
- 最终 `review-refund-full-r3.log`:退出 0,83 个迁移、12 个阶段全部通过,去重 244 个检查项(比原 224 项多独立/关联各 10 组);电脑/手机独立及关联的追回链、员工授权、借入识别及在线声明等完整回归通过,自有资源清理 PASS。两项原根因待查已按本轮复现及反例关闭,P3 上传后刷新体验保留。原三轮成功链与本轮加强矩阵分开报告,不声称新增的五类矩阵每端各跑了三遍。
|
||||
- 已通过:前端 typecheck/build、Prisma validate、错误理由/UI 合同、诊断开关三个拒绝反例、8 个 JS/CJS 语法检查、task/process/docs/governance/sensitive。docs 保留原有 22 份未登记文件的 1 条告警;缺 JSONL 在 inline 模式按官方 task validate 允许跳过。backend spec 仅同步列表筛选、渲染等待和证据 ID 选择合同,不复制运行日志。
|
||||
- 证据仍在 `artifacts/qa-flow/loan-browser-diagnostics-20261010/`,本次新增 `review-*` 回执;`review-source.json` 中 10 个受影响产品/测试源码摘要与最终运行后文件一致;`review-result.json` 为 PASS。摘要仅覆盖列出的源码,不代表整个工作区、候选门禁或生产发布,`normalReleaseEligible=false`。
|
||||
|
||||
本轮 AC46–AC51 本地范围已完成。验证完成时尚无提交授权;随后用户明确批准本地提交,见顶部收尾记录。产品有改动,正式发版前对最终提交跑完整候选门禁,再另获推送/部署批准;本轮没有迁移,可并入下次发版。父任务仍 `in_progress`,N5/N6 真实办理及暂停的登记默认值继续开放。
|
||||
|
||||
实际补验命令(运行时另提供已准备的 `PAYMENT_ORDER_PYTHON_BIN` 锁定 BIFF8 环境,不记录真实路径):
|
||||
|
||||
```sh
|
||||
RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC=authorization-pagination node scripts/qa/test-company-loan-db.cjs
|
||||
RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC=refund node scripts/qa/test-company-loan-db.cjs
|
||||
```
|
||||
|
||||
相关检查使用 `npm run typecheck`、`npm run build`、`npm run prisma:validate`、受影响脚本 `node --check`,以及官方 task validate、`check-error-reason-contract.mjs`、`check-ui-design-rules.mjs`、`check-process-contract.mjs --baseline --json`、`audit-docs.mjs --check-registry`、`check-trellis-governance.mjs`、`check-sensitive-content.mjs` 和 `git diff --check`;各回执以 `review-*` 保留。
|
||||
|
||||
## 2026-10-10 D:两项遗留失败排查结果(Claude 会话实施时点记录,提交前复核见上节)
|
||||
|
||||
用户要求由 Claude 会话按本节规划实施,附加条件为:失败时开启 trace 采证、以半天为限、不影响 N5 办理。证据目录 `artifacts/qa-flow/loan-browser-diagnostics-20261010/`,全部使用合成数据和隔离环境。
|
||||
|
||||
### D1 员工授权翻页超时:根因已证实,已修复
|
||||
|
||||
- 诊断入口:`RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC=authorization-pagination`,脚本 `scripts/qa/company-loan-pagination-diagnostic.cjs`。造 65 名可授权出借人,目标位于第 3 页;在电脑和手机上,以 CPU 降速 20 倍模拟负载。
|
||||
- 根因:原测试循环只等网络响应,不等页面渲染,就检查目标选项。渲染滞后时(例如过期数据重新获取导致第 1 页被请求两次,请求序列为 1、1、2、3),循环会再点一次“下一页”,此时按钮已禁用,于是挂到 30 秒超时,且来不及截图。这与 r6、r7 的失败形态一致。
|
||||
- 复现情况:降速下原循环在 `pagination-r4/r5-baseline`、`pagination-fixed-r1/r2` 中超时,按电脑/手机分别计,在修复后 3 轮的 6 次中失败 3 次;新循环 6/6 通过,包括往回翻页命中缓存的情况。反例“页面数据不变”时,新的等待 6/6 按要求失败。
|
||||
- 诊断中顺带发现的两个产品问题,均已修复:
|
||||
- 对话框在前端过滤出借人,可能出现空白页。现由服务端 `GET /loan-lenders?eligible=related_authority` 只返回已核实、已关联员工、且非本人的出借人;其他取值返回 400。
|
||||
- 对话框重新打开后停在上次的页码。现在 `open()` 会重置到第 1 页,6/6 验证通过。
|
||||
- 测试修复:`company-loan-employee-browser.cjs` 改为等选项列表加载出另一页后再判断;员工场景浏览器 3/3 通过(`employee-fixed-r1..r3`)。
|
||||
|
||||
### D2 登记追回到账:根因已证实(测试选证据方式),产品改动已撤回
|
||||
|
||||
- 诊断入口:`RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC=refund`,代码在 `company-loan-reconciliation-browser.cjs` 第一次追回处。上传后把批次详情的刷新延迟 4 秒,再用原来的“取最后一个选项”方式。
|
||||
- 根因:上传成功后,对话框先关闭,再在后台刷新数据。原测试立即打开登记对话框时,证据下拉只有占位项,选中占位项后必填校验拦下提交:不发 POST、提示框为空、对话框不关。这与 full-loan-r1 的空提示一致。修复前诊断(`refund-before-fix.log`)在电脑和手机上都选中了占位项。
|
||||
- 测试修复:此前已改为按上传返回的证据 ID 精确选择,保持不变。
|
||||
- 产品修复试验后撤回:曾试验“刷新完成后再关闭对话框”。`refund-after-fix-r1` 证明这样能选中正确证据,但 r2、r3 在其他对账步骤上,对话框关闭超过了现有测试的 5 秒期限,说明负载下财务端也会明显变慢,因此撤回,保持原行为。“刚上传的证据要等刷新完才能选”记为 P3 体验问题,后续可以改为刷新期间禁用下拉,不靠延迟关闭来解决。
|
||||
- 修复前和修复后的追回链(40 → 25 → 0)都在完整回归中通过。
|
||||
|
||||
### 验证
|
||||
|
||||
- Claude 转交的撤回后版本,完整借款回归共 3 轮通过,每轮去重 224 项、12 个阶段:`refund-before-fix.log`、`final-regression-r2.log`、`final-regression-r3.log`。Codex 独立核对了统计及清理 PASS;“before-fix 的代码与撤回后相同”沿用实施会话说明,旧日志本身没有源码摘要,不据此冒称独立证明了整棵源码一致。本次最终源码补验见顶部。
|
||||
- 前端类型检查与构建通过。
|
||||
- 原失败日志全部保留。
|
||||
|
||||
### 结论与边界
|
||||
|
||||
- D1:根因已证实并修复,修复包括测试等待和两个产品小问题。
|
||||
- D2:根因已证实,是测试选择方式,已修复;产品体验问题记为 P3,未修。
|
||||
- 产品改动只有两处:服务端出借人列表的可选过滤,以及授权对话框的页码重置与改用过滤。不涉及权限模型、状态机或迁移。
|
||||
- 未提交、未推送、未部署。
|
||||
|
||||
## 2026-10-10 D:两项遗留失败排查规划(规划时点,结果见上节)
|
||||
|
||||
用户本轮要求规划员工授权翻页超时与登记追回到账。沿当前 task 增补 PRD AC46–AC51 和 design 的最小实验,分 D1/D2 独立报告,优先 D1。起点 HEAD=`db919dc6`、工作区干净;此阶段未改测试/业务代码、未运行浏览器/数据库实验、未复现或修复任何根因,不将父任务进行中状态解释为新范围的实施批准。
|
||||
|
||||
- [x] D0-P:核对旧 r6/r7 response 超时、full-loan-r1 空提示,检查现有脚本/组件/路由/缓存和旧版本,明确证据缺口。事实及 file:line 集中记录在 PRD;相关历史对话已在本会话和 task,不额外检索会话原文。
|
||||
- [x] D0-P:形成范围、实验、受影响文件和可观察验收;没有新增产品行为待决问题,方案交用户审阅。复用当前借款 task,不新建目录或更改官方 current。
|
||||
- 本轮规划检查:完成新增 PRD 段落的收敛检查,无临时问答或未决产品选择;task 上下文、流程基线、Trellis 治理、敏感内容及 `git diff --check` 均通过。文档登记 0 错误,保留既有 22 份未登记文档的 1 条告警;没有业务测试运行回执,尚未提交。
|
||||
- [x] D0-E:已读受影响层规范并按合成/隔离环境运行,磁盘补验前约 7.9 GiB;定向开关约束和白名单采证已补,具体分支回执见顶部。
|
||||
- [x] D1-E:固定 65 名可选出借人及第三页目标;电脑/手机实际分页、往返缓存、重开和第三页授权/本人确认已连续三轮通过,旧/新等待及不变页反例保留。原失败的请求时间线未记录,不声称能逐帧还原原 r6/r7;本轮复现支持等待网络未等待渲染的归因。
|
||||
- [x] D2-E:独立与关联电脑/手机成功链通过,精确 ID 和原生验证/无 POST/pending/409/200 后刷新矩阵已补;20 组资金反例含追回审计失败真实回滚,详见顶部最终 r3。
|
||||
- [x] D3-E:产品修复限已证实的筛选/重开问题;旧三轮成功链保留,第三页加强场景再连续三轮,新增追回诊断矩阵每端一轮,最终完整回归去重 244 项通过。产品由 Codex 主会话只读交叉复核,新增测试主会话自查;没有虚报独立 reviewer 重跑或候选门禁。
|
||||
- [x] D4-E:当前 task 与既有 backend spec 已更新;两项遗留根因待查关闭、P3 体验问题保留,N5/N6 和暂停默认值不混入结案。提交/推送/生产操作仍须另获明确批准。
|
||||
|
||||
拟用证据目录:`artifacts/qa-flow/loan-browser-diagnostics-20261010/`,分 authorization-pagination/refund 子范围,历史证据保留。当前只读磁盘余量约 8.9 GiB;先跑定向、只留必要 trace/截图,完整回归前再核对余量,不自动删旧产物。
|
||||
|
||||
执行命令计划(本轮未运行;已有 BIFF8 Python 环境按受控本地配置提供):
|
||||
|
||||
```sh
|
||||
# 现有员工专项:新增强制分页场景后使用。
|
||||
RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_EMPLOYEE_ONLY=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs
|
||||
# 以下 diagnostic 开关为计划新增,目前尚不可用。
|
||||
RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC=authorization-pagination node scripts/qa/test-company-loan-db.cjs
|
||||
RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC=refund node scripts/qa/test-company-loan-db.cjs
|
||||
# 最终一次完整借款回归:移除 ONLY/DIAGNOSTIC 开关。
|
||||
RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs
|
||||
```
|
||||
|
||||
业务代码变化才按实际影响运行前端 typecheck/build、Prisma validate、后端语法/相关测试及错误理由/UI 检查;文档与流程检查按既有 task/process/docs/governance/sensitive/diff 合同执行。上述任务运行不包含真实账号、真实银行资料、云 OCR、生产连接或真实付款,不影响已发布 `4a5cd8f2` 的线上办理。
|
||||
|
||||
## 2026-10-10 N5-C 已发布至生产(4a5cd8f2,83/83 迁移)
|
||||
|
||||
出借人在线确认及站内通知直达已随 `4a5cd8f2b3e04da24551ddf1ee99b4167aa76710` 发布。本次按发布会话转交结果补记,只核对本地 Git、已有候选验证和部署日志,没有重新连接生产或办理真实借款。父任务继续 `in_progress`;下方各轮“未提交、未发布、待门禁”等表述保留其历史时点,以本节为最新发布状态。
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
# 公司借款台账与本金归还
|
||||
|
||||
## 2026-10-10 D:授权翻页与追回到账排查(已实施、产品复核及本地补验完成;未提交)
|
||||
|
||||
用户要求规划“员工授权翻页超时”和“登记追回到账”两项遗留问题。复用本任务作为执行合同,分成 D1/D2 两个独立验收阶段,不把当前父任务 `in_progress` 当作本轮实施批准。规划基线为 `db919dc6`,开始时工作区干净;线上业务基线仍为 `4a5cd8f2`。本阶段目标是查清失败发生在哪一层,并形成能稳定触发相关分支的回归,避免漏走分支的通过掩盖问题。
|
||||
|
||||
### 规划时点的历史证据及缺口(后续结果见 implement 顶部)
|
||||
|
||||
| 阶段 | 已确认事实及来源 | 尚未确认 |
|
||||
| --- | --- | --- |
|
||||
| D1:公司代表授权的出借人翻页 | 原 `loan-incoming-prefill-20261009/regression-r6.log`、`regression-r7.log` 为 `page.waitForResponse` 30 秒超时,旧任务记录标注手机端;没有对应失败截图。当前 `scripts/qa/company-loan-employee-browser.cjs:160` 仅在目标不在当前选项时翻页,`:163` 等待谓词不限定页码。服务端 `server/src/routes/companyLoans.js:245` 按姓名、ID 排序;`client/src/api/companyLoans.ts:52` 每页 30 条,`client/src/query/queryClient.ts:6` 查询缓存有效期 5 秒。 | 点击是否真正发生、等待是否遗漏/误捕响应、目标实际位于哪页、是否走缓存,以及是否为产品分页问题。 |
|
||||
| D2:登记追回到账 | `loan-lender-ack-20261010/full-loan-r1.log` 只有 `synthetic UI 登记追回到账:` 空提示;没有可据以归因的请求结果。旧脚本按最后一个 option 选证据;当前 `scripts/qa/company-loan-reconciliation-browser.cjs:45`–`:59` 已改为捕获上传 evidenceId 并按 ID 选择,`:32` 增加表单有效性采证,但这不能证明旧失败根因。提交在 `client/src/components/common/LoanBankReconciliation.tsx:59`,退款入账在 `server/src/services/loanBankResults.js:172`。 | 当时选中哪份证据、是否发出 POST、服务端是否拒绝/挂起、是否已经成功但刷新未完成;原日志未记录设备和第几次追回。 |
|
||||
|
||||
### 范围与顺序
|
||||
|
||||
1. D0:核对历史日志、脚本版本及最小采证方案;只使用合成资料和隔离 PostgreSQL/浏览器环境。
|
||||
2. D1 优先:固定至少 65 名满足授权选择条件的出借人,按接口实际排序校验目标在第二/第三页,并强制浏览器走到第三页、返回上一页后再进入。区分首次加载、快速响应、可控延迟、5 秒内命中缓存、弹窗关闭重开及无关同路径响应。
|
||||
3. D2 随后:保留旧失败所属的独立复核模式,并补当前关联方模式对照。固定晚到成功产生待追回 40 元、两次追回 15/25 元的现有合成路径;分别观察上传、证据选项刷新、登记、复核、入账和余额/冻结刷新,不把“弹窗关闭”当作唯一成功依据。
|
||||
4. 先用采证区分测试等待、测试资料、界面状态和服务端规则,再做对应最小修复。如果仅是测试问题,只修改测试;若证实产品问题,修复限定在该分页/登记路径。材料、权限、审批、币种、重复入账和金额边界保持现有合同。
|
||||
|
||||
不包含:生产读写/真实付款、云 OCR、数据库迁移、通用对话框重构、其他 P3、暂停的登记默认值、提交/推送/部署、清理历史证据。若结论要求改变权限、状态机合同、核算规则或迁移,先回到方案确认。相关现有权限/资金守卫的缺陷修复仍须只读独立复核。
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [x] AC46(翻页分支真实触发):电脑、手机均通过实际服务证明目标不在第一页,明确收到/命中第二、第三页数据并显示对应选项;往返及关闭重开不混入其他页或他人的选项,财务登记后由正确公司代表确认,未创建还款。
|
||||
- [x] AC47(翻页归因):采证能区分没有点击、没有请求/合法缓存命中、请求失败/等待错误、响应成功但界面错误。可还原的旧等待方式与当前/修复方式做同条件对照;故意让页码不变或返回错误页时,测试须失败。缓存命中不能被要求“必须再发一次请求”。
|
||||
- [x] AC48(追回成功链):独立复核及关联方模式的电脑/手机均实际完成上传指定证据、登记、不同身份复核、财务入账;待追回从 40 到 25 到 0,仅全额完成后关闭对应追回冻结,不关闭其他原因;原终态、本金、付款批次数及历史事实按既有规则保持。
|
||||
- [x] AC49(追回失败可定位):无有效选择/原生表单阻止提交、可控请求延迟、服务端拒绝、200 后刷新延迟分别有可区分证据;有 HTTP 结果则绑定准确批次、方法、请求 ID 与安全错误理由,无提交则明确记录无 POST,不再仅留空 alert。
|
||||
- [x] AC50(金额与重复保护):错误方向、作废/错误类别证据、超出剩余待追回金额受阻;相同请求键重试不重复创建,同一银行交易不能重复入账,已入账重放不重复计算。拒绝或事务失败不提前减待追回、关闭冻结、改本金或生成付款批次。独立及关联两模式共 20 组补验通过,回执和边界见 implement 顶部。
|
||||
- [x] AC51(验证与结案):两项定向场景各完成电脑/手机连续 3 轮(每轮新隔离环境,含明确缓存分支),随后最终版本一次完整借款回归;必要的类型/构建/后端与合同检查、按实际影响的只读复核通过。结论逐项注明“根因已证实并修复”“已补强但原事故未定”或“待进一步排查”,只有能解释历史失败并经反例对照验证的项才关闭原根因待查。
|
||||
|
||||
技术未知在 design 的实验清单中处理,没有需要先决定的新增产品行为。方案已在 Claude 会话获准实施,授权来源见 implement 顶部;本会话按同一验收范围补齐复核发现的测试证据。提交、推送、部署仍须明确授权。
|
||||
|
||||
## 2026-10-10 N5-C 已发布(4a5cd8f2,真实办理待验收)
|
||||
|
||||
出借人在线确认及站内通知直达已随 `4a5cd8f2b3e04da24551ddf1ee99b4167aa76710` 推送部署。补记时本地 HEAD 与 `origin/main` 一致、工作区干净;已有最终候选验证 PASS,部署前 13/13,唯一待应用迁移为 `20261010010000_loan_lender_acknowledgements`,部署后 83/83、无待应用迁移、合同刷新通过。公网健康、页面与资源正常及未登录接口 401 沿用发布会话转交结果;本轮只读核对本地日志,没有重新访问生产。
|
||||
|
||||
用户回报此次未做手工快照;本会话此前建议做快照,决策来源在 implement 顶部区分记录。部署日志跳过登录后的 live smoke,真实发起声明、本人确认、余额确认、公司代表授权及首笔还款尚未验收。财务/出借人按现有手册办理,代账余额核对材料及必要的系统外已还本金依据继续必需。
|
||||
|
||||
整体任务保持 `in_progress`,N5/N6、追回到账与员工授权翻页根因待查、暂停的登记默认值待办继续开放。以下“未提交、未发布、未重跑最终门禁”等表述是各轮历史记录,以 implement 顶部最新发布证据为准。本次文档补记未提交,不修改线上版本或工程合同。
|
||||
整体任务保持 `in_progress`,N5/N6 和暂停的登记默认值待办继续开放。两项浏览器遗留失败的最新诊断与本地修复见 D 节;本节及以下“根因待查、未提交、未发布、未重跑最终门禁”等表述保留各轮历史时点,以 implement 顶部最新证据为准。文档补记不修改线上版本或工程合同。
|
||||
|
||||
## 2026-10-10 N5-C 出借人在线确认借款(用户已批准实施)
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ export type LoanReceiptAttributionState = {
|
||||
export function fetchCompanyLoans(page: number, lenderId?: string, signal?: AbortSignal) {
|
||||
return apiRequestData<{ items: CompanyLoan[]; total: number }>(`/api/company-loans?page=${page}&pageSize=30${lenderId ? `&lenderId=${encodeURIComponent(lenderId)}` : ""}`, { signal });
|
||||
}
|
||||
export function fetchLoanLenders(page: number, signal?: AbortSignal) {
|
||||
return apiRequestData<{ items: LoanLender[]; total: number }>(`/api/loan-lenders?page=${page}&pageSize=30`, { signal });
|
||||
export function fetchLoanLenders(page: number, signal?: AbortSignal, eligible?: "related_authority") {
|
||||
return apiRequestData<{ items: LoanLender[]; total: number }>(`/api/loan-lenders?page=${page}&pageSize=30${eligible ? `&eligible=${eligible}` : ""}`, { signal });
|
||||
}
|
||||
export function fetchLoanRepayments(page: number, signal?: AbortSignal) {
|
||||
return apiRequestData<{ items: LoanRepayment[]; total: number }>(`/api/loan-repayments?page=${page}&pageSize=30`, { signal });
|
||||
|
||||
@@ -20,7 +20,7 @@ export function LoanRelatedAuthorizations({ finance }: { finance: boolean }) {
|
||||
const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null);
|
||||
const [requestKey, setRequestKey] = useState(() => crypto.randomUUID());
|
||||
const records = useQuery({ queryKey: ["loan-related-authorizations", me?.id, page], queryFn: ({ signal }) => fetchLoanRelatedAuthorizations(page, signal) });
|
||||
const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, "authorization", lenderPage], queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal), enabled: finance && action === "propose" });
|
||||
const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, "authorization", lenderPage], queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal, "related_authority"), enabled: finance && action === "propose" });
|
||||
const loans = useQuery({ queryKey: ["company-loans", me?.id, "authorization", lenderId, loanPage], queryFn: ({ signal }) => fetchCompanyLoans(loanPage, lenderId, signal), enabled: finance && action === "propose" && Boolean(lenderId) });
|
||||
const detail = useQuery({ queryKey: ["loan-related-authorizations", me?.id, selected?.id], queryFn: ({ signal }) => fetchLoanRelatedAuthorization(selected!.id, signal), enabled: Boolean(selected && action && action !== "propose"), staleTime: 0 });
|
||||
const mutation = useMutation({ mutationFn: async () => {
|
||||
@@ -34,7 +34,7 @@ export function LoanRelatedAuthorizations({ finance }: { finance: boolean }) {
|
||||
await Promise.all(["loan-related-authorizations", "payment-batches", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] })));
|
||||
} });
|
||||
function open(next: Action, record: LoanRelatedAuthorization | null = null) {
|
||||
mutation.reset(); setAction(next); setSelected(record); setReason(""); setLoanIds([]); setLenderId(""); setLoanPage(1); setRequestKey(crypto.randomUUID());
|
||||
mutation.reset(); setAction(next); setSelected(record); setReason(""); setLoanIds([]); setLenderId(""); setLenderPage(1); setLoanPage(1); setRequestKey(crypto.randomUUID());
|
||||
}
|
||||
const error = mutation.error ?? records.error ?? detail.error ?? lenders.error ?? loans.error;
|
||||
return <section className="space-y-4" aria-label="公司代表授权范围">
|
||||
|
||||
@@ -1,5 +1,108 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
|
||||
// Exercise the real return consumer in both approval modes. A late-success
|
||||
// audit rollback does not prove that return claims and freeze closure roll back.
|
||||
async function verifyReturnGuards({ prisma, pool, call, finance, batchId, lenderId, loanId, parentId, voidedEvidenceId, fields, upload, review, apply, prefix }) {
|
||||
const base = `/payment-batches/${batchId}`;
|
||||
const checks = [];
|
||||
const remaining = async () => {
|
||||
const settlements = await prisma.loanSupplementalSettlement.findMany({ where: { event_id: parentId } });
|
||||
const returns = await prisma.loanBankResultEvent.findMany({ where: { parent_id: parentId, kind: 'return', status: 'booked' } });
|
||||
return settlements.reduce((n, r) => n + Number(r.receivable_amount), 0) - returns.reduce((n, r) => n + Number(r.amount), 0);
|
||||
};
|
||||
async function snapshot() {
|
||||
const loan = await call(finance, 'GET', `/company-loans/${loanId}`);
|
||||
const batch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batchId } });
|
||||
const repayment = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loan_repayment_id }, include: { allocations: true } });
|
||||
const freezes = await prisma.loanFreezeReason.findMany({ where: { lender_id: lenderId }, orderBy: { id: 'asc' } });
|
||||
return {
|
||||
outstandingReturn: (await remaining()).toFixed(2), outstanding: loan.outstanding, reserved: loan.reserved,
|
||||
batchCount: await prisma.paymentBatch.count(), batchStatus: batch.status, bankState: repayment.bank_result_state,
|
||||
allocations: repayment.allocations.map(r => [r.id, r.amount.toFixed(2), r.reservation_state]).sort(),
|
||||
claims: await prisma.loanBankEventClaim.count({ where: { event: { payment_batch_id: batchId } } }),
|
||||
freezes: freezes.map(r => [r.id, r.kind, r.status]),
|
||||
supplementalCount: await prisma.loanSupplementalSettlement.count({ where: { event_id: parentId } }),
|
||||
};
|
||||
}
|
||||
const body = async (amount, overrides = {}) => ({ kind: 'return', parentId, evidenceId: await upload('incoming_return'), requestKey: randomUUID(),
|
||||
fields: fields(amount, true), attributionNote: '合成退款原件唯一定位原批次', exclusionNote: '合成逐笔排除其余付款及退款交易', ...overrides });
|
||||
const propose = (input, status = 200) => call(finance, 'POST', `${base}/loan-bank-results`, input, status,
|
||||
status === 409 ? 'loan.bank_result_reconcile_required' : undefined);
|
||||
const initial = await snapshot();
|
||||
assert.equal(initial.outstandingReturn, '40.00');
|
||||
const wrongKind = await upload('late_success');
|
||||
assert.ok((await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: voidedEvidenceId } })).voided_at);
|
||||
for (const [name, input] of [
|
||||
['wrong_direction', await body('15.00', { fields: fields('15.00', false) })],
|
||||
['wrong_evidence_kind', await body('15.00', { evidenceId: wrongKind })],
|
||||
['voided_evidence', await body('15.00', { evidenceId: voidedEvidenceId })],
|
||||
['over_remaining', await body('40.01')],
|
||||
]) {
|
||||
const eventCount = await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } });
|
||||
await propose(input, 409);
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } }), eventCount);
|
||||
assert.deepEqual(await snapshot(), initial, `${name} must not change money, claims, freezes or original facts`);
|
||||
checks.push(`${prefix}_return_${name}_refused_without_money_change`);
|
||||
}
|
||||
const input = await body('15.00');
|
||||
const partial = await propose(input);
|
||||
const eventCount = await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } });
|
||||
assert.equal((await propose(input)).id, partial.id);
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: batchId } }), eventCount);
|
||||
await propose({ ...input, fields: fields('14.00', true) }, 409);
|
||||
await review(partial); await apply(partial);
|
||||
const afterPartial = await snapshot(); assert.equal(afterPartial.outstandingReturn, '25.00');
|
||||
assert.ok(afterPartial.freezes.some(r => r[1] === 'receivable' && r[2] === 'active'));
|
||||
await apply(partial); assert.deepEqual(await snapshot(), afterPartial, 'booked replay does not count a return twice');
|
||||
checks.push(`${prefix}_return_request_key_and_booked_replay_idempotent`);
|
||||
await propose(await body('25.01'), 409);
|
||||
assert.deepEqual(await snapshot(), afterPartial, 'remaining limit is recomputed after partial return');
|
||||
checks.push(`${prefix}_return_partial_recomputes_remaining_limit`);
|
||||
|
||||
// Re-uploading the same bank transaction may create auditable proposal
|
||||
// history, but it must never gain approval or a second claim.
|
||||
const duplicate = await propose({ ...input, evidenceId: await upload('incoming_return'), requestKey: randomUUID() });
|
||||
await review(duplicate, 409); assert.deepEqual(await snapshot(), afterPartial);
|
||||
checks.push(`${prefix}_return_same_bank_transaction_cannot_be_booked_twice`);
|
||||
const rejected = await propose(await body('1.00'));
|
||||
await review(rejected, 200, { decision: 'reject' });
|
||||
await apply(rejected, 409); assert.deepEqual(await snapshot(), afterPartial);
|
||||
checks.push(`${prefix}_return_rejected_review_cannot_apply`);
|
||||
|
||||
// Keep a separate unresolved late-success cause while fully returning this
|
||||
// receivable. Closing one cause must not unfreeze the whole lender.
|
||||
const other = await propose({ ...await body('80.00'), kind: 'late_success', parentId: undefined,
|
||||
evidenceId: wrongKind, fields: fields('80.00', false) });
|
||||
const full = await propose(await body('25.00')); await review(full);
|
||||
const beforeRollback = await snapshot();
|
||||
await pool.query(`CREATE FUNCTION synthetic_return_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF NEW.action='LOAN_RETURN_BOOKED' THEN RAISE EXCEPTION 'synthetic return audit failure'; END IF; RETURN NEW; END $$`);
|
||||
await pool.query('CREATE TRIGGER synthetic_return_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_return_audit_failure()');
|
||||
try { await apply(full, 500); }
|
||||
finally {
|
||||
await pool.query('DROP TRIGGER synthetic_return_audit_failure ON audit_logs');
|
||||
await pool.query('DROP FUNCTION synthetic_return_audit_failure()');
|
||||
}
|
||||
assert.deepEqual(await snapshot(), beforeRollback, 'return audit failure rolls back claims, amount and freeze closure');
|
||||
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: full.id } })).status, 'approved');
|
||||
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: full.id } }), 0);
|
||||
checks.push(`${prefix}_return_audit_failure_rolls_back_claim_amount_and_freeze`);
|
||||
await apply(full);
|
||||
const afterFull = await snapshot(); assert.equal(afterFull.outstandingReturn, '0.00');
|
||||
assert.ok(!afterFull.freezes.some(r => r[1] === 'receivable' && r[2] === 'active'));
|
||||
assert.ok(afterFull.freezes.some(r => r[1] === 'late_success' && r[2] === 'active'));
|
||||
assert.equal(afterFull.outstanding, initial.outstanding); assert.equal(afterFull.reserved, initial.reserved);
|
||||
assert.equal(afterFull.batchCount, initial.batchCount); assert.equal(afterFull.batchStatus, initial.batchStatus);
|
||||
assert.equal(afterFull.bankState, initial.bankState); assert.deepEqual(afterFull.allocations, initial.allocations);
|
||||
assert.equal(afterFull.supplementalCount, initial.supplementalCount);
|
||||
await apply(full); assert.deepEqual(await snapshot(), afterFull);
|
||||
await review(other, 200, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: await upload('bank_terminal') });
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: lenderId, status: 'active' } }), 0);
|
||||
checks.push(`${prefix}_return_full_closes_only_receivable_and_preserves_original_facts`);
|
||||
return checks;
|
||||
}
|
||||
|
||||
module.exports = async function bankResultsFixture(h) {
|
||||
const { prisma, pool, call, app, req, finance, manager, independent, verifiedLender, confirmedLoan, repay,
|
||||
exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid } = h;
|
||||
@@ -14,7 +117,7 @@ module.exports = async function bankResultsFixture(h) {
|
||||
payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), Buffer.from(await doc.save()), Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
assert.equal(res.statusCode, 200); return res.json().data.id;
|
||||
}
|
||||
async function setup(cancelled = true) {
|
||||
async function setup(cancelled = true, prepareVoidedReturn = false) {
|
||||
const lender = await verifiedLender(`合成晚到${randomUUID().slice(0, 6)}`, null, '5');
|
||||
const loan = await confirmedLoan(lender.id, '100.00', '合成晚到借款');
|
||||
const batch = await repay(lender.id, loan.id, '80.00');
|
||||
@@ -24,9 +127,14 @@ module.exports = async function bankResultsFixture(h) {
|
||||
const ev = await uploadEvidence(batch.id);
|
||||
const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: ev });
|
||||
await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id);
|
||||
let voidedEvidenceId;
|
||||
if (prepareVoidedReturn) {
|
||||
voidedEvidenceId = await upload(batch.id, 'incoming_return');
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/loan-terminal-evidences/${voidedEvidenceId}/void`, { reason: '合成取消前作废未消费退款材料' });
|
||||
}
|
||||
if (cancelled) await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '合成原终态取消' });
|
||||
const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id } });
|
||||
return { lender, loan, batch, payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted) };
|
||||
return { lender, loan, batch, voidedEvidenceId, payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted) };
|
||||
}
|
||||
function fields(s, amount = '80.00', incoming = false, ref = `BANK-${randomUUID()}`) {
|
||||
return [{ Name: '付款账号', Value: incoming ? s.lender.bankAccount : s.payer }, { Name: '收款账号', Value: incoming ? s.payer : s.lender.bankAccount },
|
||||
@@ -65,7 +173,7 @@ module.exports = async function bankResultsFixture(h) {
|
||||
}
|
||||
checks.push('s6_reserved_conflict_is_not_overpayment_and_blocks_until_resolved');
|
||||
{
|
||||
const s = await setup();
|
||||
const s = await setup(true, true);
|
||||
// Another real payment uses the existing HTTP/OCR settlement path.
|
||||
const paid = await repay(s.lender.id, s.loan.id, '60.00');
|
||||
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
|
||||
@@ -76,10 +184,9 @@ module.exports = async function bankResultsFixture(h) {
|
||||
assert.equal(row.settled_amount.toFixed(2), '40.00'); assert.equal(row.receivable_amount.toFixed(2), '40.00');
|
||||
assert.equal((await balances(s)).outstanding, '0.00');
|
||||
await propose(s, 'return', e.id, '0.00', undefined, 409);
|
||||
const ret = await propose(s, 'return', e.id, '15.00'); await review(s, ret); await apply(s, ret);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
||||
const rest = await propose(s, 'return', e.id, '25.00'); await review(s, rest); await apply(s, rest);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
|
||||
checks.push(...await verifyReturnGuards({ prisma, pool, call, finance, batchId: s.batch.id, lenderId: s.lender.id, loanId: s.loan.id, parentId: e.id, voidedEvidenceId: s.voidedEvidenceId,
|
||||
fields: (amount, incoming) => fields(s, amount, incoming), upload: kind => upload(s.batch.id, kind),
|
||||
review: (event, status = 200, body = {}) => review(s, event, independent, body, status), apply: (event, status = 200) => apply(s, event, status), prefix: 'independent' }));
|
||||
assert.equal((await balances(s)).outstanding, '0.00');
|
||||
}
|
||||
checks.push('s6_s7_overpayment_is_difference_partial_return_keeps_frozen_full_return_closes');
|
||||
@@ -276,3 +383,4 @@ module.exports = async function bankResultsFixture(h) {
|
||||
checks.push('s5_consumed_false_alarm_correction_cannot_be_voided_in_service_or_database');
|
||||
return checks;
|
||||
};
|
||||
module.exports.verifyReturnGuards = verifyReturnGuards;
|
||||
|
||||
@@ -157,11 +157,15 @@ module.exports = async function employeeBrowser(h) {
|
||||
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached();
|
||||
// D1: a response arriving is not the page being rendered. Step only after
|
||||
// the option list has loaded a different page (also covers cache hits).
|
||||
const lenderOptions = () => dialog.getByLabel('授权出借人').locator('option').evaluateAll(os => os.map(o => o.value).join(','));
|
||||
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${lender.id}"]`).count()); i++) {
|
||||
assert.ok(i < 20);
|
||||
// Await both together so a timeout reaches the catch below (and its screenshot).
|
||||
await Promise.all([page.waitForResponse(r => r.url().includes('/loan-lenders?page=')),
|
||||
dialog.getByRole('button', { name: '下一页出借人', exact: true }).click({ timeout: 30000 })]);
|
||||
assert.ok(i < 20, 'authorized lender not found on any page');
|
||||
const before = await lenderOptions();
|
||||
const next = dialog.getByRole('button', { name: '下一页出借人', exact: true });
|
||||
await expect(next).toBeEnabled({ timeout: 10000 }); await next.click();
|
||||
await expect.poll(async () => { const now = await lenderOptions(); return now.split(',').length > 1 && now !== before; }, { timeout: 15000 }).toBe(true);
|
||||
}
|
||||
await dialog.getByLabel('授权出借人').selectOption(lender.id);
|
||||
await dialog.getByRole('checkbox', { name: new RegExp(loan.loan_no) }).check();
|
||||
|
||||
@@ -421,6 +421,10 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
// Incoming-receipt scenarios add lenders; in a full run they go last so they
|
||||
// cannot shift the pagination other browser scenarios rely on.
|
||||
const runIncoming = () => require('./company-loan-incoming-fixture.cjs')({ ...employeeHarness, verifiedLender });
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'authorization-pagination') {
|
||||
await require('./company-loan-pagination-diagnostic.cjs')({ ...employeeHarness, verifiedLender, confirmedLoan });
|
||||
return;
|
||||
}
|
||||
const runLenderAck = () => require('./company-loan-lender-ack-fixture.cjs')({ ...employeeHarness, verifiedLender, admin, system });
|
||||
if (process.env.RUN_ISOLATED_LOAN_ACK_ONLY === '1') {
|
||||
const lenderAck = await runLenderAck();
|
||||
@@ -1389,7 +1393,9 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
await receipt(target.id, '700', reference, { payeeAccount: failLender.bankAccount, transactionStatus: 'failed' });
|
||||
reconciliationUi.failures.push({ id: target.id, repaymentId: target.loanRepaymentId, reference });
|
||||
|
||||
const lateLender = await verifiedLender(`合成追回界面-${mode}`, null, '8');
|
||||
// The company pays from the synthetic 8-prefix account. Keep the
|
||||
// lender different so reversing a return is a genuine counterexample.
|
||||
const lateLender = await verifiedLender(`合成追回界面-${mode}`, null, '9');
|
||||
const lateLoan = await confirmedLoan(lateLender.id, '100.00', `合成晚到追回-${mode}`);
|
||||
const original = await repay(lateLender.id, lateLoan.id, '80.00');
|
||||
await call(manager, 'POST', `/payment-batches/${original.id}/review`, {}); await exportOrder(original.id);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
const { mkdtempSync, mkdirSync } = require('node:fs');
|
||||
const { createRequire } = require('node:module');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const clientRequire = createRequire(path.join(root, 'client/package.json'));
|
||||
const { chromium, expect } = clientRequire('@playwright/test');
|
||||
|
||||
// D1 diagnostic: the company-representative authorization dialog pages its
|
||||
// lender list (30 per page). With the target lender on page 3, compare the old
|
||||
// test loop (response-only wait) with a loop that waits for the option list to
|
||||
// change, under CPU throttling that models a loaded host. Synthetic data only.
|
||||
async function authorizationDialog(page) {
|
||||
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click({ timeout: 30000 });
|
||||
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached({ timeout: 30000 });
|
||||
return dialog;
|
||||
}
|
||||
const optionValues = (dialog) => dialog.getByLabel('授权出借人').locator('option').evaluateAll((os) => os.map((o) => o.value).join(','));
|
||||
|
||||
// The original loop from company-loan-employee-browser.cjs (before D1).
|
||||
async function oldLoop(page, dialog, targetId, timeout) {
|
||||
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${targetId}"]`).count()); i++) {
|
||||
assert.ok(i < 20);
|
||||
await Promise.all([page.waitForResponse((r) => r.url().includes('/loan-lenders?page='), { timeout }),
|
||||
dialog.getByRole('button', { name: '下一页出借人', exact: true }).click({ timeout })]);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the rendered options to change after each page step (works for
|
||||
// network responses and cache hits alike).
|
||||
async function stepTo(dialog, direction) {
|
||||
const before = await optionValues(dialog);
|
||||
const button = dialog.getByRole('button', { name: direction === 'next' ? '下一页出借人' : '上一页出借人', exact: true });
|
||||
await expect(button).toBeEnabled({ timeout: 10000 });
|
||||
await button.click();
|
||||
// The list empties while a page loads; only a loaded, different page counts.
|
||||
await expect.poll(async () => { const now = await optionValues(dialog); return now.split(',').length > 1 && now !== before; },
|
||||
{ timeout: 15000 }).toBe(true);
|
||||
}
|
||||
async function newLoop(dialog, targetId) {
|
||||
const target = dialog.getByLabel('授权出借人').locator(`option[value="${targetId}"]`);
|
||||
while (await dialog.getByRole('button', { name: '上一页出借人', exact: true }).isEnabled()) await stepTo(dialog, 'previous');
|
||||
for (let i = 0; !(await target.count()); i++) { assert.ok(i < 20, 'target lender not found on any page'); await stepTo(dialog, 'next'); }
|
||||
}
|
||||
|
||||
module.exports = async function paginationDiagnostic(h) {
|
||||
const { app, prisma, call, finance, manager, password, config, verifiedLender, confirmedLoan } = h;
|
||||
const checks = [];
|
||||
// Fillers are eligible for the dialog (verified, linked to their own employee),
|
||||
// so the target is reached only by real paging, with or without a server filter.
|
||||
for (let n = 0; n < 64; n++) {
|
||||
const staff = await h.actor(['employee']);
|
||||
await verifiedLender(`合成翻页-${String(n).padStart(2, '0')}`, staff.id, '5');
|
||||
}
|
||||
const target = await verifiedLender('合成翻页-目标', manager.id, '3');
|
||||
const loan = await confirmedLoan(target.id, '100.00', '合成翻页目标借款');
|
||||
// Explicit exclusions prove that filtering is applied before pagination and
|
||||
// count, including the current finance actor and an unverified linked lender.
|
||||
const own = await verifiedLender('合成翻页-本人排除', finance.id, '4');
|
||||
const unverified = await call(finance, 'POST', '/loan-lenders', { legalEntityId: h.company.id, name: '合成翻页-未核实排除',
|
||||
bankInstitutionId: h.bank.id, bankName: '合成', bankAccount: '9'.repeat(16), accountName: '合成翻页-未核实排除', requestKey: randomUUID() });
|
||||
// The dialog pages the server-filtered list of lenders it can offer.
|
||||
const pages = [];
|
||||
for (let p = 1; ; p++) {
|
||||
const r = await call(finance, 'GET', `/loan-lenders?page=${p}&pageSize=30&eligible=related_authority`);
|
||||
assert.ok(r.items.every((i) => i.identityStatus === 'verified' && i.employeeId && i.employeeId !== finance.id), 'filter returns only offerable lenders');
|
||||
pages.push(r.items.map((i) => i.id)); if (p * 30 >= r.total) break;
|
||||
}
|
||||
const all = await call(finance, 'GET', '/loan-lenders?page=1&pageSize=30');
|
||||
await call(finance, 'GET', '/loan-lenders?page=1&pageSize=30&eligible=other', undefined, 400);
|
||||
for (const who of [manager, h.ordinary]) await call(who, 'GET', '/loan-lenders?eligible=related_authority', undefined, 403);
|
||||
assert.ok(!pages.flat().includes(own.id) && !pages.flat().includes(unverified.id));
|
||||
assert.equal(pages.flat().length, 65, 'eligible total excludes unlinked, unverified and self lenders');
|
||||
checks.push(`eligible_filter_total_${pages.flat().length}_of_${all.total}`);
|
||||
const targetPage = pages.findIndex((ids) => ids.includes(target.id)) + 1;
|
||||
assert.ok(targetPage >= 3, `target lender must be on page 3 or later, observed ${targetPage}`);
|
||||
checks.push(`target_on_page_${targetPage}_of_${pages.length}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
|
||||
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const base = path.join(root, 'artifacts/qa-flow/loan-browser-diagnostics-20261010/authorization-pagination'); mkdirSync(base, { recursive: true });
|
||||
const output = mkdtempSync(path.join(base, 'run-'));
|
||||
const oldOrigin = config.appOrigin; config.appOrigin = origin;
|
||||
const results = [];
|
||||
const record = (entry) => { results.push(entry); console.log(JSON.stringify({ stage: 'loan_authorization_pagination_step', ...entry })); };
|
||||
try {
|
||||
for (const mode of ['desktop', 'mobile']) {
|
||||
const context = await browser.newContext({ viewport: mode === 'mobile' ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
await context.route('**/*', (route) => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
||||
await context.tracing.start({ screenshots: true, snapshots: true });
|
||||
const page = await context.newPage();
|
||||
const requests = [];
|
||||
page.on('request', (r) => { if (r.url().includes('/loan-lenders?page=')) requests.push(new URL(r.url()).searchParams.get('page')); });
|
||||
try {
|
||||
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mode === 'mobile' ? '/mobile' : ''}/company-loans`)}`);
|
||||
await page.getByTestId('login-identifier').fill(finance.email); await page.getByTestId('login-password').fill(password);
|
||||
await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
|
||||
const cdp = await context.newCDPSession(page);
|
||||
|
||||
// 0. Counterexample before any paging (only page 1 cached): page 2 is answered
|
||||
// with page 1's data, so the list never changes and the new wait must fail.
|
||||
let dialog = await authorizationDialog(page);
|
||||
await page.route('**/api/loan-lenders?page=*', (route) => {
|
||||
const url = new URL(route.request().url()); url.searchParams.set('page', '1');
|
||||
return route.continue({ url: url.toString() });
|
||||
});
|
||||
await stepTo(dialog, 'next').then(() => { throw new Error('a page that never changes must not satisfy the wait'); },
|
||||
(error) => { assert.ok(/toBe|Timeout|expect/i.test(String(error.message)), String(error.message).slice(0, 200)); });
|
||||
await page.unroute('**/api/loan-lenders?page=*');
|
||||
record({ mode, step: 'unchanged_page_counterexample', outcome: 'new wait fails as required' });
|
||||
await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
await page.reload(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible({ timeout: 30000 });
|
||||
requests.length = 0;
|
||||
|
||||
// 1. Old loop under a loaded host: does it reproduce the timeout?
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 20 });
|
||||
dialog = await authorizationDialog(page);
|
||||
let oldOutcome = 'pass';
|
||||
try { await oldLoop(page, dialog, target.id, 8000); } catch (error) { oldOutcome = error.name === 'TimeoutError' ? 'timeout' : `error:${error.name}`; }
|
||||
const landed = await dialog.getByLabel('授权出借人').locator(`option[value="${target.id}"]`).count();
|
||||
await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||
record({ mode, step: 'old_loop_throttled', outcome: oldOutcome, targetVisibleAfter: landed > 0, requests: [...requests] });
|
||||
|
||||
// 2. Reopen: which lender page does the dialog start on?
|
||||
requests.length = 0;
|
||||
dialog = await authorizationDialog(page);
|
||||
const firstPage = (await optionValues(dialog)).split(',').filter(Boolean);
|
||||
const startsOnPage = pages.findIndex((ids) => ids.some((id) => firstPage.includes(id))) + 1;
|
||||
record({ mode, step: 'reopen_start_page', startsOnPage });
|
||||
assert.equal(startsOnPage, 1, 'reopening the dialog starts from the first lender page');
|
||||
assert.deepEqual(firstPage, pages[0], 'reopening renders precisely the first page');
|
||||
|
||||
// 3. New loop, throttled, including returning to page 1 first.
|
||||
await newLoop(dialog, target.id);
|
||||
assert.equal(await dialog.getByLabel('授权出借人').locator(`option[value="${target.id}"]`).count(), 1);
|
||||
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 1]);
|
||||
// 4. Cache: step back and forward again; the forward step may be served from cache.
|
||||
const beforeCache = requests.length;
|
||||
await stepTo(dialog, 'previous');
|
||||
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 2]);
|
||||
await stepTo(dialog, 'next');
|
||||
assert.deepEqual((await optionValues(dialog)).split(',').filter(Boolean), pages[targetPage - 1]);
|
||||
record({ mode, step: 'new_loop_throttled_with_cache', outcome: 'pass', requestsDuringBackForward: requests.length - beforeCache });
|
||||
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 1 });
|
||||
|
||||
// The third-page target must be usable, not merely visible. Finance
|
||||
// proposes this scope and its designated representative confirms it.
|
||||
await dialog.getByLabel('授权出借人').selectOption(target.id);
|
||||
await dialog.getByRole('checkbox', { name: new RegExp(loan.loanNo) }).check();
|
||||
await dialog.getByLabel('代表权及授权范围依据').fill('合成第三页目标公司代表权及借款范围');
|
||||
const proposed = page.waitForResponse(r => new URL(r.url()).pathname === '/api/loan-related-authorizations' && r.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
const proposalResponse = await proposed; assert.equal(proposalResponse.status(), 200);
|
||||
const authorizationId = (await proposalResponse.json()).data.id;
|
||||
await expect(dialog).not.toBeVisible();
|
||||
const representative = await browser.newContext({ viewport: mode === 'mobile' ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
try {
|
||||
await representative.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
||||
const rp = await representative.newPage();
|
||||
await rp.goto(`${origin}/login?redirect=${encodeURIComponent(`${mode === 'mobile' ? '/mobile' : ''}/company-loans`)}`);
|
||||
await rp.getByTestId('login-identifier').fill(manager.email); await rp.getByTestId('login-password').fill(password);
|
||||
await rp.getByTestId('login-submit').click(); await expect(rp.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
|
||||
await rp.getByRole('button', { name: '代表公司确认授权范围', exact: true }).click();
|
||||
const rd = rp.getByRole('dialog'); await expect(rd).toContainText(loan.loanNo);
|
||||
await rd.getByLabel('确认或停止的理由').fill('合成本人核对第三页借款及公司代表范围');
|
||||
const confirmed = rp.waitForResponse(r => new URL(r.url()).pathname === `/api/loan-related-authorizations/${authorizationId}/actions` && r.request().method() === 'POST');
|
||||
await rd.getByRole('button', { name: '确认', exact: true }).click();
|
||||
assert.equal((await confirmed).status(), 200); await expect(rd).not.toBeVisible();
|
||||
} finally { await representative.close(); }
|
||||
const saved = await prisma.loanRelatedAuthorization.findUniqueOrThrow({ where: { id: authorizationId }, include: { events: true, loans: true } });
|
||||
assert.equal(saved.approver_id, manager.id); assert.equal(saved.proposed_by, finance.id);
|
||||
assert.deepEqual(saved.loans.map(r => r.loan_id), [loan.id]);
|
||||
assert.ok(saved.events.some(r => r.action === 'confirm' && r.actor_id === manager.id));
|
||||
assert.ok(await prisma.auditLog.count({ where: { action: 'LOAN_RELATED_AUTHORIZATION_DECIDED', target_id: authorizationId } }));
|
||||
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: target.id } }), 0);
|
||||
record({ mode, step: 'third_page_scope_proposed_and_confirmed', outcome: 'pass', zeroRepayments: true });
|
||||
|
||||
checks.push(`${mode}_pagination_diagnosed`);
|
||||
} finally {
|
||||
await context.tracing.stop({ path: path.join(output, `trace-${mode}.zip`) });
|
||||
await page.screenshot({ path: path.join(output, `final-${mode}.png`), fullPage: true }).catch(() => {});
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
} finally { config.appOrigin = oldOrigin; await browser.close(); }
|
||||
assert.ok(loan.id);
|
||||
console.log(JSON.stringify({ stage: 'loan_authorization_pagination_diagnostic', status: 'PASS', checks, results, output: path.relative(root, output) }));
|
||||
return results;
|
||||
};
|
||||
@@ -10,12 +10,23 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
for (const mobile of [false, true]) {
|
||||
const mode = mobile ? 'mobile' : 'desktop';
|
||||
let view;
|
||||
let traceNumber = 0;
|
||||
async function closeView() {
|
||||
if (!view) return;
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund') {
|
||||
await view.context.tracing.stop({ path: path.join(output, `${mode}-refund-${++traceNumber}.zip`) });
|
||||
}
|
||||
await view.context.close(); view = null;
|
||||
}
|
||||
const uploadedByKind = new Map();
|
||||
async function login(who, batch) {
|
||||
if (view) await view.context.close();
|
||||
await closeView();
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
||||
const page = await context.newPage();
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund') {
|
||||
await context.tracing.start({ screenshots: true, snapshots: true });
|
||||
}
|
||||
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batch.id}`)}`);
|
||||
await page.getByTestId('login-identifier').fill(who.email);
|
||||
await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click();
|
||||
@@ -63,6 +74,91 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
await d.getByLabel('回单流水号').fill(reference);
|
||||
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成官方查询逐一排除其他同金额付款和退款交易');
|
||||
}
|
||||
async function diagnoseRefund(late) {
|
||||
const page = view.page;
|
||||
const endpoint = `/api/payment-batches/${late.id}/loan-bank-results`;
|
||||
const detail = url => new URL(url).pathname === `/api/payment-batches/${late.id}`;
|
||||
const post = r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST';
|
||||
const record = (step, extra = {}) => console.log(JSON.stringify({ stage: 'loan_refund_submission_diagnostic', mode, step, batchId: late.id, ...extra }));
|
||||
// Hold the actual refresh, rather than hoping an arbitrary sleep outlasts
|
||||
// the browser. The old strategy must select the sole placeholder.
|
||||
let releaseRefresh;
|
||||
const refreshGate = new Promise(resolve => { releaseRefresh = resolve; });
|
||||
const started = page.waitForRequest(r => detail(r.url()) && r.method() === 'GET');
|
||||
const slow = async route => { if (route.request().method() === 'GET') await refreshGate; await route.continue(); };
|
||||
await page.route(detail, slow);
|
||||
let submitted = 0;
|
||||
const countPost = request => { if (new URL(request.url()).pathname === endpoint && request.method() === 'POST') submitted++; };
|
||||
page.on('request', countPost);
|
||||
try {
|
||||
await upload('incoming_return'); await started;
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click();
|
||||
const d = page.getByRole('dialog'); const select = d.getByLabel('对应的成功银行回单');
|
||||
const values = await select.locator('option').evaluateAll(os => os.map(o => o.value));
|
||||
assert.deepEqual(values, ['']); await select.selectOption(values.at(-1));
|
||||
await d.getByLabel('回单付款账号').fill(late.payee); await d.getByLabel('回单收款账号').fill(late.payer);
|
||||
await d.getByLabel('回单人民币金额').fill('15.00'); await d.getByLabel('回单流水号').fill(`${late.reference}-OLD-PICK`);
|
||||
await d.getByLabel('为什么不属于其他候选批次或交易').fill('合成原选项策略对照'); await d.getByLabel('依据与说明').fill('合成旧策略原生表单校验探针');
|
||||
assert.deepEqual(await d.locator('input:invalid, select:invalid, textarea:invalid').evaluateAll(es => es.map(el => el.getAttribute('aria-label'))), ['对应的成功银行回单']);
|
||||
await select.evaluate(el => { el.dataset.syntheticInvalid = '0'; el.addEventListener('invalid', () => { el.dataset.syntheticInvalid = '1'; }); });
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(select).toHaveAttribute('data-synthetic-invalid', '1');
|
||||
assert.equal(submitted, 0); await expect(d).toBeVisible(); assert.deepEqual(await d.getByRole('alert').allTextContents(), []);
|
||||
record('native_validation_no_post', { oldSelectionPicks: 'placeholder', postCount: submitted, formBlocked: true });
|
||||
releaseRefresh();
|
||||
const uploadedId = uploadedByKind.get('incoming_return');
|
||||
await expect(select.locator(`option[value="${uploadedId}"]`)).toBeAttached({ timeout: 20000 });
|
||||
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
|
||||
} finally { releaseRefresh(); page.off('request', countPost); await page.unroute(detail, slow); }
|
||||
|
||||
// A real business refusal is distinguishable from native validation and
|
||||
// leaves a nonempty error tied to this exact POST and server request ID.
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click();
|
||||
let d = page.getByRole('dialog');
|
||||
await bankFields(d, late, '15.00', `${late.reference}-WRONG-DIRECTION`, true);
|
||||
await d.getByLabel('回单付款账号').fill(late.payer); await d.getByLabel('回单收款账号').fill(late.payee);
|
||||
await d.getByLabel('依据与说明').fill('合成错误方向拒绝诊断');
|
||||
const refused = page.waitForResponse(post);
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click();
|
||||
const refusal = await refused; assert.equal(refusal.status(), 409);
|
||||
const safeError = await refusal.json(); assert.equal(safeError.error.details.reasonCode, 'loan.bank_result_reconcile_required'); assert.ok(safeError.requestId);
|
||||
await expect(d.getByRole('alert')).toContainText(/\S/);
|
||||
record('server_refused', { method: 'POST', status: 409, requestId: safeError.requestId, reasonCode: safeError.error.details.reasonCode });
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: late.id, kind: 'return' } }), 0);
|
||||
await page.keyboard.press('Escape'); await expect(d).not.toBeVisible();
|
||||
|
||||
// Hold the registration request, then hold the refresh after a real 200.
|
||||
// Database checks separate request pending from committed-but-stale UI.
|
||||
let releasePost;
|
||||
const postGate = new Promise(resolve => { releasePost = resolve; });
|
||||
const eventRoute = url => new URL(url).pathname === endpoint;
|
||||
const atPost = page.waitForRequest(r => eventRoute(r.url()) && r.method() === 'POST');
|
||||
const delayedPost = async route => { await postGate; await route.continue(); };
|
||||
await page.route(eventRoute, delayedPost);
|
||||
let releaseAfter;
|
||||
const afterGate = new Promise(resolve => { releaseAfter = resolve; });
|
||||
const atAfter = page.waitForRequest(r => detail(r.url()) && r.method() === 'GET');
|
||||
const delayedAfter = async route => { if (route.request().method() === 'GET') await afterGate; await route.continue(); };
|
||||
await page.route(detail, delayedAfter);
|
||||
try {
|
||||
await view.panel.getByRole('button', { name: '登记追回到账', exact: true }).click(); d = page.getByRole('dialog');
|
||||
await bankFields(d, late, '15.00', `${late.reference}-RETURN-15.00`, true); await d.getByLabel('依据与说明').fill('合成请求与成功后刷新延迟诊断');
|
||||
const succeeded = page.waitForResponse(post);
|
||||
await d.getByRole('button', { name: '确认', exact: true }).click(); await atPost;
|
||||
await expect(d.getByRole('button', { name: '处理中…', exact: true })).toBeDisabled();
|
||||
assert.equal(await prisma.loanBankResultEvent.count({ where: { payment_batch_id: late.id, kind: 'return' } }), 0);
|
||||
record('post_pending', { method: 'POST', eventCount: 0 });
|
||||
releasePost(); const response = await succeeded; assert.equal(response.status(), 200);
|
||||
const result = await response.json(); const event = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: result.data.id } });
|
||||
assert.equal(event.evidence_id, uploadedByKind.get('incoming_return')); assert.equal(event.amount.toFixed(2), '15.00'); assert.equal(event.status, 'proposed');
|
||||
const parent = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: event.parent_id } });
|
||||
assert.equal(parent.payment_batch_id, late.id); assert.equal(parent.kind, 'late_success'); assert.equal(parent.status, 'booked');
|
||||
await atAfter; await expect(d).not.toBeVisible();
|
||||
record('post_200_refresh_pending', { method: 'POST', status: 200, requestId: result.requestId, eventId: event.id, evidenceId: event.evidence_id });
|
||||
releaseAfter(); await expect(view.panel).toContainText(/追回到账 \d+ · ¥15\.00 · 待复核/);
|
||||
record('refresh_rendered', { eventId: event.id });
|
||||
} finally { releasePost(); releaseAfter(); await page.unroute(eventRoute, delayedPost); await page.unroute(detail, delayedAfter); }
|
||||
}
|
||||
try {
|
||||
const failed = fixtures.failures[Number(mobile)];
|
||||
await login(finance, failed);
|
||||
@@ -111,8 +207,12 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
await review(); await login(finance, late); await action('登记补记或追回');
|
||||
await expect(view.panel).toContainText('待追回 ¥40.00');
|
||||
for (const [amount, remaining] of [['15.00', '25.00'], ['25.00', '0.00']]) {
|
||||
await upload('incoming_return');
|
||||
await action('登记追回到账', d => bankFields(d, late, amount, `${late.reference}-RETURN-${amount}`, true));
|
||||
if (process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC === 'refund' && amount === '15.00') {
|
||||
await diagnoseRefund(late);
|
||||
} else {
|
||||
await upload('incoming_return');
|
||||
await action('登记追回到账', d => bankFields(d, late, amount, `${late.reference}-RETURN-${amount}`, true));
|
||||
}
|
||||
await login(manager, late); await review(); await login(finance, late); await action('登记补记或追回');
|
||||
await expect(view.panel).toContainText(`待追回 ¥${remaining}`);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: late.lenderId, status: 'active' } }), remaining === '0.00' ? 0 : 1);
|
||||
@@ -137,7 +237,7 @@ module.exports = async function reconciliationBrowser({ browser, origin, output,
|
||||
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
await view.page.screenshot({ path: path.join(output, `${mode}-late-return-and-dismissal.png`), fullPage: true });
|
||||
checks.push(`${mode}_evidenced_duplicate_false_alarm_closed_by_independent_reviewer_via_ui`);
|
||||
} finally { if (view) await view.context.close(); }
|
||||
} finally { await closeView(); }
|
||||
}
|
||||
return checks;
|
||||
};
|
||||
|
||||
@@ -471,7 +471,13 @@ module.exports = async function relatedFixture(h) {
|
||||
}
|
||||
checks.push('rp_only_payee_conflict_waived_multiple_roles_tokens_and_q6_refused');
|
||||
for (const [cancelled, paidAmount, reserveAmount] of [[false, '0.00', '0.00'], [true, '0.00', '70.00'], [true, '60.00', '0.00'], [true, '100.00', '0.00']]) {
|
||||
const s = await setup('100.00'); const b = await batch(s, '80.00'); await terminate(s, b, cancelled);
|
||||
const s = await setup('100.00'); const b = await batch(s, '80.00');
|
||||
let voidedEvidenceId;
|
||||
if (paidAmount === '60.00') {
|
||||
voidedEvidenceId = await evidence(b, 'incoming_return');
|
||||
await call(finance, 'POST', `${base(b)}/loan-terminal-evidences/${voidedEvidenceId}/void`, { reason: '合成取消前作废未消费退款材料' });
|
||||
}
|
||||
await terminate(s, b, cancelled);
|
||||
if (paidAmount !== '0.00') {
|
||||
const p = await batch(s, paidAmount); await receipt(p.id, String(Math.round(Number(paidAmount) * 100)), `RP-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
await call(finance, 'POST', `${base(p)}/execute`, {});
|
||||
@@ -489,7 +495,12 @@ module.exports = async function relatedFixture(h) {
|
||||
assert.equal((await balance(s)).reserved, '0.00');
|
||||
if (excess) {
|
||||
await authorization(s.lender, [s.loan]);
|
||||
for (const value of [15, excess - 15]) {
|
||||
if (excess === 40) checks.push(...await require('./company-loan-bank-results-fixture.cjs').verifyReturnGuards({
|
||||
prisma, pool, call, finance, batchId: b.id, lenderId: s.lender.id, loanId: s.loan.id, parentId: e.id, voidedEvidenceId,
|
||||
fields: (amount, incoming) => fields(s, amount, `RP-RETURN-GUARD-${randomUUID()}`, incoming), upload: kind => evidence(b, kind),
|
||||
review: (event, status = 200, more = {}) => review(b, event, 'loan-bank-results', status, manager, more),
|
||||
apply: (event, status = 200) => apply(b, event, status), prefix: 'related' }));
|
||||
else for (const value of [15, excess - 15]) {
|
||||
const ret = await bankEvent(s, b, 'return', value.toFixed(2), e.id); await review(b, ret, 'loan-bank-results'); await apply(b, ret);
|
||||
if (value === 15) assert.ok(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,13 @@ const { Pool } = serverRequire('pg');
|
||||
|
||||
async function main() {
|
||||
assert.equal(process.env.RUN_ISOLATED_LOAN_DB_TEST, '1');
|
||||
const diagnostic = process.env.RUN_ISOLATED_LOAN_BROWSER_DIAGNOSTIC;
|
||||
if (diagnostic !== undefined) {
|
||||
assert.ok(['authorization-pagination', 'refund'].includes(diagnostic), 'unknown loan browser diagnostic');
|
||||
assert.equal(process.env.RUN_ISOLATED_LOAN_BROWSER, '1', 'browser diagnostic requires the browser switch');
|
||||
assert.ok(!['RUN_ISOLATED_LOAN_RELATED_ONLY', 'RUN_ISOLATED_LOAN_ACK_ONLY', 'RUN_ISOLATED_LOAN_INCOMING_ONLY', 'RUN_ISOLATED_LOAN_EMPLOYEE_ONLY']
|
||||
.some(key => process.env[key] === '1'), 'diagnostic must not be shadowed by an ONLY switch');
|
||||
}
|
||||
const scratch = mkdtempSync(path.join(tmpdir(), 'hyb-loan-synthetic-'));
|
||||
const socket = path.join(scratch, 'socket');
|
||||
const dockerConfig = path.join(scratch, 'docker');
|
||||
|
||||
@@ -239,8 +239,11 @@ async function companyLoansRoutes(fastify) {
|
||||
});
|
||||
fastify.get("/loan-lenders", auth, async (request, reply) => {
|
||||
ensureLoanManager(fastify, request);
|
||||
const query = parse(LIST.omit({ lenderId: true }), request.query ?? {});
|
||||
const where = { archived_at: null };
|
||||
const query = parse(LIST.omit({ lenderId: true }).extend({ eligible: z.enum(["related_authority"]).optional() }).strict(), request.query ?? {});
|
||||
// The authorization dialog pages only lenders it can offer, so no page is
|
||||
// empty after client-side filtering.
|
||||
const where = { archived_at: null, ...(query.eligible === "related_authority"
|
||||
? { identity_status: "verified", employee_id: { not: null }, NOT: { employee_id: request.employee.id } } : {}) };
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.loanLender.findMany({ where, include: LENDER_INCLUDE, orderBy: [{ name: "asc" }, { id: "asc" }],
|
||||
skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
|
||||
|
||||
在新工单中引用
屏蔽一个用户