比较提交
2
次代码提交
36b949ecf6
...
c251729267
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
c251729267 | ||
|
|
3a4a5969bb |
@@ -1,3 +1,15 @@
|
||||
## 2026-10-11 G:材料区域最小精简设计(已批准并实施)
|
||||
|
||||
沿用 `CompanyLoansPage.tsx` 的现有材料区域、表单和 tab 切换,保持现有组件样式。本轮只调整信息显示,不迁移到新详情页、不复制 `LoanLenderAcknowledgements` 的发起流程,也不新建材料管理面板。
|
||||
|
||||
- 材料是否满足只从当前借款 DTO 推导:未归档、审核可用的 `incoming` 和 `accounting_confirmation`;有效 `lenderAcknowledgement` 或审核可用的 `agreement`;当登记期初未还小于原本金时还需审核可用的 `adjustment`。金额按现有精确金额处理约定比较,不拿后续 `outstanding` 代替期初值。
|
||||
- 上传控件按 PRD G 的条件显示,既有材料链接保留;协议入口注明二选一。余额材料始终提供原 `accounting_confirmation` 上传入口,前台“余额依据说明”继续提交 `accountingConfirmationRef`,不更名数据库或 API。
|
||||
- 原 `setTab("acknowledgements")` / `setTab("authorizations")` 用于跳转;无有效声明且已关联员工时提示到原栏目查看/办理,未关联员工保留原纸质路径。现有借款 DTO 不足以判断 pending/returned 或授权是否完成,本轮不补聚合查询、不抓取全量分页,以中性导航文案表达。
|
||||
- 缺项投影与按钮禁用共用同一判断;上传提交、等待列表刷新及刷新失败期间不使用旧快照启用确认。返回页面或确认栏目后按现有查询约定更新数据;服务端继续负责防止并发版本变化、声明失效和资料缺失的提交。
|
||||
- 确认完成后的授权链接仅用于适用既有关联方授权条件的借款;外部出借人继续原独立审批路径。保持借款声明、余额确认、公司代表授权三种不同业务动作,不自动推进任何状态。
|
||||
|
||||
界面仅提示财务如何准备银行流水/代账明细和填写出处,不宣称上传即完成余额核对。实施时同步现有操作手册对应段落,注明银行流水作为附件核对,不是新增银行流水导入能力。
|
||||
|
||||
## 2026-10-10 D:浏览器遗留失败排查设计(已批准实施、产品复核及本地补验完成,未提交)
|
||||
|
||||
沿当前借款任务 AC46–AC51,先补最小可观察实验,再定位并修复;不预判两项都是测试脚本问题。没有迁移、生产连接或新的业务规则。
|
||||
|
||||
@@ -1,4 +1,46 @@
|
||||
## 2026-10-11 F:员工与公司组合唯一约束——实施(用户已批准;未提交)
|
||||
## 2026-10-12 G:只读复核 P2 修复(确认弹窗跟随最新列表状态;复核关闭、回归通过;未提交)
|
||||
|
||||
另一会话只读复核(`review-20261012-codex.json`):材料规则 1,536 组前后端对照一致;P2 R1——确认弹窗打开后列表刷新中、刷新失败或在线确认失效时,弹窗“确认”仍可点击并以打开时快照保存(服务端仍会拒绝,非绕过)。
|
||||
|
||||
- 修复:确认模式按 `selectedLoan.id` 从当前列表取最新记录,按钮禁用与 `submit` 共用 `confirmBlocker`:列表刷新中、刷新失败或找不到该借款、版本变化或已确认(要求关闭后重新核对,不静默替换快照)、材料缺项;弹窗内 `role=status` 显示原因。
|
||||
- 测试:原“上传后刷新失败”改为等待页面错误提示出现后再断言(失败时列表整体隐藏,按钮消失亦视为不可确认);新增电脑/手机“弹窗已打开”检查——等待 5 秒过期后派发 offline/online 触发真实 React Query 重连刷新,依次覆盖刷新挂起、最终失败、在线确认失效,断言按钮禁用、回车零保存请求,恢复后可保存。
|
||||
- 结果:修复后完整回归 `full-regression-r3/r4.log` 各退出 0、84 迁移、12 阶段、去重 251 项(249 + 电脑/手机弹窗跟随最新状态 2 项),清理 PASS;前端 typecheck/build 通过。另一会话补充复核 `review-followup-20261012-codex.json`:电脑/手机 12 组场景通过,P2 关闭,无新阻断项(复核时回归尚未结束,结果以本节日志为准)。
|
||||
- 反证:去掉按钮与提交两处判断(保留提示)后专项失败于“弹窗确认应禁用实际可用”(`ack-browser-negative-without-fix.log`);恢复修复后 `ack-browser-fix-r5.log` 通过。过程失败保留:`ack-browser-fix-r1`(失败刷新后卡片整体隐藏,断言过窄)、`-r2`(挂起请求放行前即撤销拦截致重复处理)、`-r3`(错误态重挂载使材料区折叠)、`ack-browser-negative-invalid-build-failed.log`(反证改动未过类型检查、构建未生效,作废)、`ack-browser-negative-masked-by-route.log`(失败原因被拦截重复处理掩盖)。
|
||||
|
||||
## 2026-10-11 G:材料区域精简实施(用户已批准;本地验证完成;复核 P2 已于 10-12 修复,见上节)
|
||||
|
||||
- 页面 `client/src/pages/CompanyLoansPage.tsx`:页内 `usableEvidence`(未归档且 `governanceStatus=approved`)、`needsAdjustment`(按登记期初未还 `openingOutstandingPrincipal` 与原本金以分比较,不用 `outstanding`)、`missingEvidence`(镜像服务端材料规则)与 `evidenceSlots`。原件已满足显示“已保存,可查看”;有效在线确认时协议框改为“借款确认 · 出借人已在线确认”,否则注明二选一,关联员工的出借人给出“去出借人确认查看或办理”跳转;余额材料始终为“上传余额核对材料”并注明“公司银行流水或代账明细账,覆盖从借入到截至日”;历史还款依据仅在登记期初小于原本金或已有文件时出现。未通过检查的文件标注“(未通过检查,不计入)”。
|
||||
- 按钮“确认借款余额”;上方 `还缺:…` 与禁用共用 `missingEvidence`,另在列表刷新中、刷新失败或上传处理中禁用。确认弹窗字段改为“余额依据说明”,下方给示例、不预填;API 仍提交 `accountingConfirmationRef`。余额已确认且出借人为已核实的关联员工时显示“查看或办理公司代表授权”。不改后端、接口、数据库与权限。
|
||||
- 测试:三个浏览器脚本改用新文案;出借人确认场景中原“缺余额材料点击确认得 409”改为前置断言按钮禁用与缺项(服务端 409 仍由 HTTP 夹具覆盖)。新增电脑/手机各一项边界检查:仅改写列表响应中目标借款字段,验证当前未还下降不出历史还款依据、登记期初下降出现并列为缺项、材料被隔离不计入、在线确认失效后协议框与跳转重现、上传后刷新失败禁用确认;真实上传与确认仍走后端。
|
||||
- 操作手册 `docs/试运行与自动化验证手册.md` 同步:余额材料可为银行流水或代账明细(银行流水仅作附件由财务核对),正常 OA 还款不再要求历史还款依据,缺项前置与“余额依据说明”。
|
||||
- 验证:前端 typecheck/build 通过;出借人确认专项(含浏览器)`ack-browser-r1.log` 通过;完整回归 `full-regression-r1/r2.log` 各退出 0、84 迁移、12 阶段、去重 249 项(247 + 电脑/手机材料显示边界 2 项),清理 PASS。证据 `artifacts/qa-flow/loan-material-slots-20261011/`。待另一会话只读复核;提交与发布另行授权。
|
||||
|
||||
## 2026-10-11 G:材料区域精简规划(已批准)
|
||||
|
||||
用户认可只显示当前所需事项,并明确本次余额依据采用公司银行流水。已核对页面、借款 DTO、后端材料要求和确认/授权入口,选择最小页面方案,需求与验收见 PRD G / AC60–64,技术边界见 design G;本轮不改产品代码。
|
||||
|
||||
- [x] 规划收敛:复用已有确认栏目;材料按有效性与期初余额显隐;使用中性余额文案;缺项前置提示;授权使用查看/办理链接。确认中/退回及授权未办不能从现有借款 DTO 推断,已改为中性跳转文案。
|
||||
- [x] 本轮仅三个任务文档新增规划,任务校验、流程基线、治理(含敏感内容扫描)与文档登记检查通过;文档仍有既有 22 文件未登记告警。修正新增段落的位置与末尾空行后 `git diff --check` 通过;未执行产品测试。
|
||||
- [ ] 具体方案取得实施批准后,修改 `client/src/pages/CompanyLoansPage.tsx`,必要时仅提取页内展示判断以便验证;不改 API、数据库或权限/状态规则。
|
||||
- [ ] 针对性覆盖:有/无有效在线确认、纸质依据替代、原件缺失、被隔离/归档材料、历史已还/正常系统还款、上传刷新与失败、返回后声明失效、确认说明和栏目跳转。采用合成资料,不访问生产。
|
||||
- [ ] 前端 typecheck/build、电脑/手机受影响浏览器场景及 `trellis-check`;若现有浏览器脚本绑定旧文案,更新其准确定位与断言,保留业务守卫,按实际影响决定相关回归范围。
|
||||
- [ ] 同步 `docs/试运行与自动化验证手册.md` 的借款材料说明,补验证证据并运行任务/流程/文档/敏感信息检查。无新增通用规则时不扩写 spec;本轮沿用现有样式,不新增视觉方案。
|
||||
|
||||
实施与本地验收获批后连续完成;提交和最终候选发布门禁、推送部署仍按原授权边界办理。本规划不包含此前“未还本金默认全额、日期默认当日”的待办。
|
||||
|
||||
## 2026-10-11 `36b949ec` 已发布(含迁移 84;未做快照)
|
||||
|
||||
用户授权提交、推送并部署,明确不做快照。部署前生产只读核对:重复组合仍为 0 组(出借人 1 条),13 项部署前检查通过;发布来源检查要求候选已推送且工作区干净,故先暂存本记录、以 HEAD=`36b949ec` 推送(`4a5cd8f2..36b949ec`)和部署,部署后再提交记录(提交记录会改变 HEAD、使门禁回执不再匹配)。
|
||||
|
||||
- 部署:`EXPECTED_PENDING_MIGRATIONS=20261011010000_loan_lender_employee_unique`;生产迁移对账 `pending=` 正好该迁移,回滚窗口 OPEN。构建 init 镜像阶段 SSH 被远端关闭,本地脚本以 `DEPLOY_EXISTING_FAILED` 退出 1;远端部署继续完成,未重试部署。
|
||||
- 部署后只读核对:迁移完成且未回滚,已应用迁移 84;唯一索引 `loan_lenders_legal_entity_id_employee_id_key` 存在;服务端容器 `BUILD_SHA=36b949ec…`;补跑只读 preflight `pending=none`,13 项 PASS。公网 `/ready` 200 且 `buildSha=36b949ecf6a3`,首页 200、前端包含同一构建号;`/login` 以浏览器 `Accept: text/html` 访问 200(无该头时为接口 404,属路由设计)。
|
||||
- 日志 `artifacts/qa-flow/loan-lender-employee-unique-20261011/deploy-36b949ec.log`、`postdeploy-36b949ec.log`。本次未做登录后线上冒烟;借款新行为需随 N5/N6 真实办理或另行授权的线上冒烟验收。
|
||||
|
||||
## 2026-10-11 发布候选 `36b949ec` 正式门禁 PASS
|
||||
|
||||
用户授权开始门禁。干净工作区、独占浏览器副本运行 `verify-candidate`:84 个迁移全部应用;服务端 230 文件 4670 通过/1 项既有跳过,另 2 文件 74 通过;本地浏览器 372 全部通过(27.1 分钟),零失败;离线 `release-gate-result.mjs require` 通过,`status=PASS`、`deploymentEligible=true`。回执 `artifacts/release-gate/release-36b949ec-20261011/`,日志 `artifacts/qa-flow/loan-lender-employee-unique-20261011/gate-36b949ec.log`;浏览器副本已删除。候选包含 `91fe31f0`、`3c5cc544`、`36b949ec` 三处线上行为变化,待执行迁移仅 `20261011010000_loan_lender_employee_unique`。推送、部署、发布窗口 AC59 重复计数与生产只读核对均待另行授权;快照由用户决定。
|
||||
|
||||
## 2026-10-11 F:员工与公司组合唯一约束——实施(用户已批准;已本地提交 `36b949ec`)
|
||||
|
||||
- 迁移 `20261011010000_loan_lender_employee_unique`(迁移总数 83→84):`CREATE UNIQUE INDEX "loan_lenders_legal_entity_id_employee_id_key" ON loan_lenders(legal_entity_id, employee_id)`;Prisma `@@unique([legal_entity_id, employee_id])`。普通唯一约束,归档行计入、空 `employee_id` 互不冲突,与现行代码规则一致;不改数据、不回填。
|
||||
- 冲突提示:新增 `isEmployeeLenderUniqueViolation`(同时识别 `meta.target` 与驱动适配器的约束字段/名称)。从员工创建路径先判它再判请求键重复;旧“确认出借人身份”入口在事务外映射。触到约束时返回原有 409 `loan.employee_lender_conflict`,不报 500,也不误报为 `loan.duplicate_registration`。原员工行锁与代码检查保持不变。
|
||||
|
||||
@@ -1,13 +1,42 @@
|
||||
# 公司借款台账与本金归还
|
||||
|
||||
## 2026-10-11 F:员工与公司组合数据库唯一约束(已实施、本地验证与只读复核完成;未提交)
|
||||
## 2026-10-11 G:借款材料区域精简(用户已批准;已实施,本地验证与只读复核完成,复核 P2 已修复关闭;未提交)
|
||||
|
||||
### 目标与已确认方向
|
||||
|
||||
用户同意只显示当前需要办理的事项,选择公司银行流水作为本次余额核对材料来源,并要求按 KISS 原则收敛。采用现有页面的条件显示与文案调整;复用“出借人确认”栏目,不另建发起入口。本节是具体待批方案,不代表产品已变更。
|
||||
|
||||
### 范围
|
||||
|
||||
1. 原借入凭据已满足要求时,收起上传控件,显示“已保存,可查看”;无有效材料时显示上传。材料只有未归档且 `governanceStatus=approved` 才算满足,其他状态不能冒充已齐全。
|
||||
2. 有服务端返回的有效在线确认时,收起协议上传控件,显示“出借人已在线确认”。没有有效在线确认时保留原协议入口,并明确“有效在线确认或协议/借款确认书,任选一种”;关联员工的出借人可跳转原“出借人确认”栏目。已有文件仍可查看,不新增“其他确认方式”折叠区。
|
||||
3. 系统外已还本金依据只在登记的 `openingOutstandingPrincipal < originalPrincipal` 时显示上传;不能使用随正常还款减少的当前余额 `outstanding` 判断。既有依据仍可查看。
|
||||
4. 余额核对材料始终显示,按钮为“上传余额核对材料”,说明为“公司银行流水或代账明细账,覆盖从借入到截至日”。确认表单的“代账余额确认引用”改名“余额依据说明”,示例为“公司银行流水,借入日至核对截止日,已逐笔核对借入及还款”;不自动填入“已核对”声明。服务端字段名称和现有必填规则不变,说明与材料由财务实际核对。
|
||||
5. “确认期初余额”统一改名“确认借款余额”。提交前就近列出缺项,材料不足、数据加载/刷新或上传处理中禁用确认;齐全后仍由财务显式确认,保留原金额、截止日、版本与说明校验。余额材料上传可以与等待出借人确认并行。
|
||||
6. 借款卡片只呈现可由当前数据确证的确认状态。现有 DTO 只有有效在线确认及可重发标记,没有未发起/待本人确认/退回状态;无有效确认时用“去出借人确认查看或办理”,不猜测为“等待”或“已退回”,不为本轮增加后端字段或扫描分页列表。余额确认后,对适用现有公司代表授权流程的借款显示“查看或办理公司代表授权”跳转;不把未查询的授权当成未办理,不要求重复授权。
|
||||
|
||||
本次银行流水作为余额核对附件,由财务核对其覆盖期间及相关借还款;不新增银行流水导入、自动对账或自动判定材料真实性。原借入凭据、借款确认依据、余额核对依据及必要的历史还款依据四类服务端要求继续有效,公司代表授权继续单独办理。
|
||||
|
||||
### 验收
|
||||
|
||||
- [x] AC60:有效入账凭据已存在、在线确认有效且期初未还等于原本金时,只显示余额核对材料上传控件;其他已存文件可查看。缺原件、无有效在线确认和有历史还款时,各自出现对应入口与准确缺项提示。
|
||||
- [x] AC61:余额材料与确认表单采用上述中性文案和银行流水示例;不再要求材料只能来自代账,不替财务自动填写核对声明;“确认借款余额”名称在入口、弹窗和操作提示一致。
|
||||
- [x] AC62:上传但尚未有效、被隔离、归档或刷新失败的材料不能使确认按钮误启用;有效在线确认失效并刷新后重新显示缺项。材料齐全仍保留服务端最终校验和确认说明必填。
|
||||
- [x] AC63:复用现有确认/授权栏目跳转,不新增发起流程;卡片不猜测等待/退回/授权状态。正常系统还款不会触发历史还款依据要求,已办理事项不被引导重复办理。
|
||||
- [x] AC64:前端类型检查、构建和电脑/手机针对性验证通过,覆盖上述显隐、缺项、刷新与跳转;原借款后端规则、权限、数据库及历史材料保持。
|
||||
|
||||
### 本轮边界
|
||||
|
||||
只规划页面、相关测试与操作手册的同步;不改后端、接口或数据库,不引入新视觉体系,不调整此前仅记录的“登记时金额/日期默认值”问题。具体方案获批后再实施;提交、发布按现有流程另批。
|
||||
|
||||
## 2026-10-11 F:员工与公司组合数据库唯一约束(已发布 `36b949ec`)
|
||||
|
||||
生产只读排查重复组合为 0 组后,用户批准把“同一公司与员工最多一个出借人(归档计入)”落到数据库。结果见 implement 顶部 F 节。
|
||||
|
||||
- [x] AC56:数据库拒绝同一公司与员工的第二个出借人,含归档行;空员工的手工出借人可多条共存。
|
||||
- [x] AC57:应用触到约束时返回原有“出借人冲突”提示,不报 500、不误报请求重复;代码检查与员工行锁保持。
|
||||
- [x] AC58:借款完整回归通过,并经独立只读复核(涉及迁移)。两轮完整回归各 84 迁移、12 阶段、去重 247 项通过;另一会话只读复核无阻断项、20 个内存探针通过,唯一 P2(缺本迁移回退说明)已在下方补齐。
|
||||
- [ ] AC59:发布窗口重跑存量重复计数为 0,并按 1 个待执行迁移部署。
|
||||
- [x] AC59:发布窗口重跑存量重复计数为 0,并按 1 个待执行迁移部署(2026-10-11:重复 0 组;迁移对账仅该迁移,部署后 pending=none、唯一索引存在)。
|
||||
|
||||
### 迁移回退说明(`20261011010000_loan_lender_employee_unique`)
|
||||
|
||||
|
||||
@@ -22,6 +22,20 @@ import { downloadBlob } from "../lib/download";
|
||||
|
||||
const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据",
|
||||
accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" };
|
||||
const usableEvidence = (loan: CompanyLoan, kind: string) =>
|
||||
loan.evidences.some((e) => e.kind === kind && !e.archivedAt && e.governanceStatus === "approved");
|
||||
// Amounts arrive as fixed two-decimal strings; compare in cents. Uses the
|
||||
// registered opening balance, not `outstanding`, which falls with OA repayments.
|
||||
const cents = (amount: string) => BigInt(amount.replace(".", ""));
|
||||
const needsAdjustment = (loan: CompanyLoan) => cents(loan.openingOutstandingPrincipal) < cents(loan.originalPrincipal);
|
||||
// Mirrors the server's evidence rule for balance confirmation so the card can
|
||||
// name what is missing up front; the server still decides on submit.
|
||||
function missingEvidence(loan: CompanyLoan) {
|
||||
const required = ["incoming", "accounting_confirmation"];
|
||||
if (!loan.lenderAcknowledgement) required.push("agreement");
|
||||
if (needsAdjustment(loan)) required.push("adjustment");
|
||||
return required.filter((kind) => !usableEvidence(loan, kind));
|
||||
}
|
||||
const stateLabels: Record<string, string> = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" };
|
||||
type Mode = "lender" | "employeeLender" | "employeeRefresh" | "loanReceipt" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
|
||||
const selectClass = "min-h-11 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
|
||||
@@ -70,8 +84,20 @@ export function CompanyLoansPage() {
|
||||
mutation.reset(); setRequestKey(crypto.randomUUID()); setMode(next); setSelectedLender(lender); setSelectedLoan(loan);
|
||||
setLenderId(lender?.id ?? ""); setAmounts({}); setIdentitySearch(""); setCandidatePage(1);
|
||||
}
|
||||
// The confirm dialog follows the live list, not the snapshot taken when it
|
||||
// opened: a refresh, a failed refresh, a changed loan or a newly missing
|
||||
// material blocks saving until the finance user reopens and rechecks.
|
||||
const liveLoan = mode === "confirm" && selectedLoan ? loans.data?.items.find((l) => l.id === selectedLoan.id) : undefined;
|
||||
const liveMissing = liveLoan ? missingEvidence(liveLoan) : [];
|
||||
const confirmBlocker = mode !== "confirm" ? null
|
||||
: loans.isFetching ? "借款列表正在刷新,请稍候。"
|
||||
: loans.isError || !liveLoan ? "借款列表未能刷新,请关闭后重新打开。"
|
||||
: liveLoan.version !== selectedLoan?.version || liveLoan.confirmationStatus === "confirmed" ? "借款信息已变化,请关闭后重新核对。"
|
||||
: liveMissing.length ? `还缺:${liveMissing.map((kind) => evidenceLabels[kind]).join("、")}` : null;
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); const values = new FormData(event.currentTarget);
|
||||
event.preventDefault();
|
||||
if (confirmBlocker) return;
|
||||
const values = new FormData(event.currentTarget);
|
||||
const text = (key: string) => String(values.get(key) ?? "").trim();
|
||||
const actorId = me?.id;
|
||||
mutation.mutate(async () => {
|
||||
@@ -113,6 +139,22 @@ export function CompanyLoansPage() {
|
||||
const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments;
|
||||
const tableTab = tab === "loans" || tab === "lenders" || tab === "repayments";
|
||||
const modalQueryBlocked = mode === "lender" ? !companies.isSuccess || !banks.isSuccess : mode === "account" ? !banks.isSuccess : mode === "identity" ? !people.isSuccess : mode === "loan" ? !lenders.isSuccess : mode === "repay" ? !lenders.isSuccess || !candidates.isSuccess : false;
|
||||
// Only the material a loan still needs offers an upload; satisfied or
|
||||
// inapplicable kinds collapse to a status line, keeping existing files viewable.
|
||||
function evidenceSlots(loan: CompanyLoan) {
|
||||
const slots: Array<{ kind: string; label: string; note?: string; upload: boolean; action?: ReactNode }> = [];
|
||||
const hasIncoming = usableEvidence(loan, "incoming");
|
||||
slots.push({ kind: "incoming", label: evidenceLabels.incoming, note: hasIncoming ? "已保存,可查看" : undefined, upload: !hasIncoming });
|
||||
slots.push(loan.lenderAcknowledgement
|
||||
? { kind: "agreement", label: "借款确认", note: "出借人已在线确认", upload: false }
|
||||
: { kind: "agreement", label: evidenceLabels.agreement, note: "有效在线确认或协议/借款确认书,任选一种", upload: true,
|
||||
action: loan.lender.employeeId ? <Button type="button" variant="link" className="px-0" onClick={() => setTab("acknowledgements")}>去出借人确认查看或办理</Button> : undefined });
|
||||
slots.push({ kind: "accounting_confirmation", label: "上传余额核对材料", note: "公司银行流水或代账明细账,覆盖从借入到截至日", upload: true });
|
||||
if (needsAdjustment(loan) || loan.evidences.some((e) => e.kind === "adjustment" && !e.archivedAt)) {
|
||||
slots.push({ kind: "adjustment", label: evidenceLabels.adjustment, note: "登记的未还本金少于原本金,请补充此前还款的依据", upload: needsAdjustment(loan) });
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
const currentPage = tab === "loans" ? page : tab === "lenders" ? lenderPage : repayPage;
|
||||
const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage;
|
||||
return <ListWorkspace>
|
||||
@@ -139,17 +181,24 @@ export function CompanyLoansPage() {
|
||||
</div>
|
||||
<p className="break-words text-sm">原借入凭据:{loan.originReference}</p>
|
||||
<details><summary className="min-h-11 cursor-pointer py-3 text-sm font-medium">借款材料与余额确认</summary>
|
||||
{loan.lenderAcknowledgement ? <p className="my-2 text-sm">已有有效的出借人在线确认,可作为借款确认材料;代账余额核对材料仍需上传。</p>
|
||||
: <p className="my-2 text-sm">协议或借款确认书也可由有效的出借人在线确认替代,请在“出借人确认”中发起。</p>}
|
||||
<div className="grid gap-3 md:grid-cols-2">{Object.entries(evidenceLabels).map(([kind, label]) => <div key={kind} className="rounded-md border p-3">
|
||||
<Label htmlFor={`${loan.id}-${kind}`}>{label}</Label>
|
||||
<Input id={`${loan.id}-${kind}`} type="file" className="mt-2" accept=".pdf,.png,.jpg,.jpeg,.webp" disabled={mutation.isPending}
|
||||
<div className="grid gap-3 md:grid-cols-2">{evidenceSlots(loan).map(({ kind, label, note, upload, action }) => <div key={kind} className="rounded-md border p-3">
|
||||
{upload ? <Label htmlFor={`${loan.id}-${kind}`}>{label}</Label> : <p className="text-sm font-medium">{label}</p>}
|
||||
{note ? <p className="mt-1 text-sm text-zinc-600">{note}</p> : null}
|
||||
{upload ? <Input id={`${loan.id}-${kind}`} type="file" className="mt-2" accept=".pdf,.png,.jpg,.jpeg,.webp" disabled={mutation.isPending}
|
||||
onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = "";
|
||||
if (file) mutation.mutate(() => uploadLoanEvidence(loan.id, kind, file)); }} />
|
||||
if (file) mutation.mutate(() => uploadLoanEvidence(loan.id, kind, file)); }} /> : null}
|
||||
{action}
|
||||
{loan.evidences.filter((e) => e.kind === kind && !e.archivedAt).map((e) => <Button key={e.id} variant="link" className="max-w-full whitespace-normal break-all"
|
||||
onClick={() => { setEvidence({ loanId: loan.id, id: e.id, name: e.originalName }); open("download"); }}>{e.originalName}</Button>)}
|
||||
onClick={() => { setEvidence({ loanId: loan.id, id: e.id, name: e.originalName }); open("download"); }}>{e.originalName}{e.governanceStatus === "approved" ? "" : "(未通过检查,不计入)"}</Button>)}
|
||||
</div>)}</div>
|
||||
{loan.confirmationStatus !== "confirmed" ? <Button className="mt-3" onClick={() => open("confirm", loan.lender, loan)}>确认期初余额</Button> : null}
|
||||
{loan.confirmationStatus !== "confirmed" ? (() => {
|
||||
const missing = missingEvidence(loan);
|
||||
return <div className="mt-3 space-y-2">
|
||||
{missing.length ? <p id={`${loan.id}-missing`} className="text-sm text-amber-800">还缺:{missing.map((kind) => evidenceLabels[kind]).join("、")}</p> : null}
|
||||
<Button disabled={missing.length > 0 || loans.isFetching || loans.isError || mutation.isPending} aria-describedby={missing.length ? `${loan.id}-missing` : undefined}
|
||||
onClick={() => open("confirm", loan.lender, loan)}>确认借款余额</Button></div>;
|
||||
})() : loan.lender.employeeId && loan.lender.identityStatus === "verified"
|
||||
? <Button variant="link" className="mt-2 px-0" onClick={() => setTab("authorizations")}>查看或办理公司代表授权</Button> : null}
|
||||
</details>
|
||||
</section>) : null}
|
||||
{tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) => <section key={lender.id} className="card-std space-y-3 p-4">
|
||||
@@ -177,9 +226,9 @@ export function CompanyLoansPage() {
|
||||
onClose={() => setMode(null)} onManual={() => open("loan")} onSaved={() => { setMode(null); setTab("loans"); setPage(1); }} /> : null}
|
||||
<Dialog open={mode !== null && mode !== "employeeLender" && mode !== "employeeRefresh" && mode !== "loanReceipt"} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
|
||||
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>
|
||||
{({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
|
||||
{({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认借款余额", repay: "申请归还本金", download: "下载借款材料" } as Record<string, string>)[mode ?? ""]}
|
||||
</DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" :
|
||||
mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
|
||||
mode === "confirm" ? "请根据凭据与公司银行流水或代账明细核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
|
||||
mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
|
||||
@@ -200,7 +249,9 @@ export function CompanyLoansPage() {
|
||||
<Field label="实际到账日期"><Input name="received" required type="date" /></Field><Field label="余额核对截止日"><Input name="asOf" required type="date" /></Field>
|
||||
<Field label="登记性质"><select name="entry" className={selectClass}><option value="historical">历史借入登记</option><option value="current">当期实际借入</option></select></Field></> : null}
|
||||
{mode === "confirm" ? <><p>原本金 ¥{selectedLoan?.originalPrincipal},核对日未还 ¥{selectedLoan?.openingOutstandingPrincipal}</p>
|
||||
<Field label="代账余额确认引用"><Input required name="reference" minLength={2} maxLength={200} /></Field></> : null}
|
||||
{confirmBlocker ? <p id="loan-confirm-blocker" role="status" className="text-sm text-amber-800">{confirmBlocker}</p> : null}
|
||||
<Field label="余额依据说明"><Input required name="reference" minLength={2} maxLength={200} aria-describedby="loan-confirm-reference-example" /></Field>
|
||||
<p id="loan-confirm-reference-example" className="text-sm text-zinc-600">示例:公司银行流水,借入日至核对截止日,已逐笔核对借入及还款。</p></> : null}
|
||||
{mode === "repay" ? <><Field label="出借人"><select required className={selectClass} value={lenderId} onChange={(e) => { setLenderId(e.target.value); setAmounts({}); setCandidatePage(1); }}><option value="">请选择</option>
|
||||
{lenders.data?.items.map((l) => <option key={l.id} value={l.id}>{l.name} · {l.legalEntityName}</option>)}</select></Field>
|
||||
<p className="text-sm text-zinc-600">填写已确认借款的分配金额,留空表示本次不归还。翻页保留已填写金额。</p>
|
||||
@@ -210,7 +261,7 @@ export function CompanyLoansPage() {
|
||||
{mode === "repay" && lenderId ? <div className="flex flex-wrap items-center gap-2"><Button type="button" variant="outline" disabled={candidatePage === 1} onClick={() => setCandidatePage(candidatePage - 1)}>上一页借款</Button>
|
||||
<span>第 {candidatePage} 页 · 已填 {Object.values(amounts).filter((v) => v.trim()).length} 笔</span><Button type="button" variant="outline" disabled={!candidates.data || candidatePage * 30 >= candidates.data.total} onClick={() => setCandidatePage(candidatePage + 1)}>下一页借款</Button></div> : null}
|
||||
{["identity", "repay", "download"].includes(mode ?? "") ? <Field label={mode === "download" ? "下载用途" : "说明"}><Input name="reason" required minLength={2} maxLength={500} /></Field> : null}
|
||||
<Button type="submit" disabled={mutation.isPending || modalQueryBlocked || mode === "repay" && !Object.values(amounts).some((v) => v.trim())}>{mutation.isPending ? "处理中…" : mode === "repay" ? "提交还款申请" : "确认"}</Button>
|
||||
<Button type="submit" disabled={mutation.isPending || modalQueryBlocked || Boolean(confirmBlocker) || mode === "repay" && !Object.values(amounts).some((v) => v.trim())}>{mutation.isPending ? "处理中…" : mode === "repay" ? "提交还款申请" : "确认"}</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
+1
-1
@@ -381,7 +381,7 @@ PROJECT_CLOSURE_CHECKLIST_ENABLED=true
|
||||
|
||||
财务发起后,系统只给该出借人创建一条 OA 站内“借款声明待本人确认”通知,不发送企业微信或其他外部消息。出借人使用本人 OA 账号,在电脑或手机的通知中心找到通知(可筛选“借款确认”),点击后直接打开本次声明。普通员工没有常驻“公司借款”入口;财务和公司代表保留原入口。出借人查看明细和原件,勾选本人声明并填写说明后确认;有误则填写原因退回财务。财务不能代确认,普通员工出借人也无需经理权限。历史通知仍可查看,但失效声明须由财务重新发起,不能通过旧通知恢复确认。线上记录作为系统接受的借款确认材料,不因留痕即宣称当然等同纸质签字的法律效力。
|
||||
|
||||
财务仍须上传代账余额核对材料;截至日未还本金少于原本金时,还须系统外已还本金依据。有效在线确认可替代协议或借款确认书附件,之后由财务填写代账引用并确认期初余额,再单独登记公司代表授权、由有权公司代表确认。出借人声明不自动完成余额确认、授权或付款审批。关键金额、日期、原件或出借人关联变化会使整组旧声明失效,恢复旧值也须重新发起;旧记录保留。已经确认余额的借款,如曾有在线声明且现已失效,仍可重新发起出借人确认,原余额确认记录不被改写。身份资料刷新导致公司代表授权失效时,授权还须另行重新办理。
|
||||
财务仍须上传余额核对材料(公司银行流水或代账明细账,覆盖从借入到核对截止日;银行流水仅作为附件由财务核对,不是银行流水导入或自动对账);登记的核对日未还本金少于原本金时,还须系统外已还本金依据,此后在 OA 内正常还款不会再要求该依据。有效在线确认可替代协议或借款确认书附件。“借款台账 → 借款材料与余额确认”只对仍缺的材料显示上传,已满足的显示状态并可查看原文件,并在“确认借款余额”按钮上方列出缺项;材料齐全后由财务填写“余额依据说明”(如实注明依据,如“公司银行流水,借入日至核对截止日,已逐笔核对借入及还款”)并确认借款余额,再单独登记公司代表授权、由有权公司代表确认。出借人声明不自动完成余额确认、授权或付款审批。关键金额、日期、原件或出借人关联变化会使整组旧声明失效,恢复旧值也须重新发起;旧记录保留。已经确认余额的借款,如曾有在线声明且现已失效,仍可重新发起出借人确认,原余额确认记录不被改写。身份资料刷新导致公司代表授权失效时,授权还须另行重新办理。
|
||||
|
||||
开发验证沿隔离借款 runner,`RUN_ISOLATED_LOAN_ACK_ONLY=1` 选择声明专项,`RUN_ISOLATED_LOAN_BROWSER=1` 加电脑/手机操作;先构建前端,并设置已有 BIFF8 Python 环境。真实人员确认由相应本人办理;后续生产迁移另行批准,不用合成回执替代真实业务验收。
|
||||
|
||||
|
||||
@@ -76,15 +76,23 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
const card = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${loan.loan_no}`, exact: true }) });
|
||||
await findOnPages(page, card, 'company-loans');
|
||||
await card.locator('summary').click();
|
||||
// No online statement for an external lender: the agreement is offered as
|
||||
// one of two options, and every gap is named before confirmation.
|
||||
await expect(card).toContainText('有效在线确认或协议/借款确认书,任选一种');
|
||||
await expect(card.getByRole('button', { name: '去出借人确认查看或办理', exact: true })).toHaveCount(0);
|
||||
await expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveText('还缺:原借入凭据、余额核对材料、协议或借款确认书');
|
||||
await expect(card.getByRole('button', { name: '确认借款余额', exact: true })).toBeDisabled();
|
||||
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
|
||||
const received = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST');
|
||||
await page.locator(`[id="${loan.id}-${kind}"]`).setInputFiles({ name: 'synthetic.pdf', mimeType: 'application/pdf',
|
||||
buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) });
|
||||
assert.equal((await received).status(), 200);
|
||||
}
|
||||
await card.getByRole('button', { name: '确认期初余额' }).click();
|
||||
await expect(card.locator(`[id="${loan.id}-incoming"]`)).toHaveCount(0);
|
||||
await expect(card).toContainText('已保存,可查看');
|
||||
await card.getByRole('button', { name: '确认借款余额' }).click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('代账余额确认引用').fill('合成代账核对');
|
||||
await dialog.getByLabel('余额依据说明').fill('合成银行流水核对');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(card).toContainText('余额已确认');
|
||||
|
||||
@@ -150,8 +150,8 @@ module.exports = async function employeeBrowser(h) {
|
||||
buffer: Buffer.from(`%PDF-1.4\n% synthetic-${kind}-${loan.id}\n1 0 obj << /Type /Catalog >> endobj\n%%EOF\n`) });
|
||||
assert.equal((await result).status(), 200);
|
||||
}
|
||||
await loanCard.getByRole('button', { name: '确认期初余额', exact: true }).click();
|
||||
await page.getByRole('dialog').getByLabel('代账余额确认引用').fill('合成会计往来余额核对记录'); await submit(page);
|
||||
await loanCard.getByRole('button', { name: '确认借款余额', exact: true }).click();
|
||||
await page.getByRole('dialog').getByLabel('余额依据说明').fill('合成会计往来余额核对记录'); await submit(page);
|
||||
await expect(loanCard).toContainText('余额已确认');
|
||||
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click();
|
||||
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
|
||||
|
||||
@@ -183,29 +183,138 @@ module.exports = async function lenderAckBrowser(h) {
|
||||
await fp.reload();
|
||||
const loan = s.loans[0];
|
||||
const card = fp.locator('section.card-std').filter({ has: fp.getByRole('heading', { name: `${s.lender.name} · ${loan.loanNo}`, exact: true }) });
|
||||
for (let i = 0; i < 30 && !await card.count(); i++) {
|
||||
const first = fp.locator('section.card-std').first(); await expect(first).toBeVisible();
|
||||
if (await card.count()) break;
|
||||
const oldText = await first.textContent(); const next = fp.getByRole('button', { name: '下一页', exact: true });
|
||||
await expect(next).toBeEnabled();
|
||||
await Promise.all([fp.waitForResponse(r => new URL(r.url()).pathname === '/api/company-loans'), next.click()]);
|
||||
await expect(first).not.toHaveText(oldText);
|
||||
}
|
||||
await card.locator('summary').click(); await expect(card).toContainText('已有有效的出借人在线确认');
|
||||
await card.getByRole('button', { name: '确认期初余额', exact: true }).click();
|
||||
dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间');
|
||||
const denied = fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`));
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click(); assert.equal((await denied).status(), 409);
|
||||
await expect(dialog.getByRole('alert')).toBeVisible(); await dialog.press('Escape'); await expect(dialog).not.toBeVisible();
|
||||
const showCard = async () => {
|
||||
for (let i = 0; i < 30 && !await card.count(); i++) {
|
||||
const first = fp.locator('section.card-std').first(); await expect(first).toBeVisible();
|
||||
if (await card.count()) break;
|
||||
const oldText = await first.textContent(); const next = fp.getByRole('button', { name: '下一页', exact: true });
|
||||
await expect(next).toBeEnabled();
|
||||
await Promise.all([fp.waitForResponse(r => new URL(r.url()).pathname === '/api/company-loans'), next.click()]);
|
||||
await expect(first).not.toHaveText(oldText);
|
||||
}
|
||||
};
|
||||
await showCard();
|
||||
await card.locator('summary').click(); await expect(card).toContainText('出借人已在线确认');
|
||||
// With the original saved and a valid online statement, only the balance
|
||||
// material is still needed: no agreement or repayment-evidence upload, and
|
||||
// confirmation stays disabled with the gap named up front.
|
||||
await expect(card).toContainText('已保存,可查看');
|
||||
await expect(card.locator(`[id="${loan.id}-agreement"]`)).toHaveCount(0);
|
||||
await expect(card.locator(`[id="${loan.id}-incoming"]`)).toHaveCount(0);
|
||||
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(0);
|
||||
await expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveText('还缺:余额核对材料');
|
||||
await expect(card.getByRole('button', { name: '确认借款余额', exact: true })).toBeDisabled();
|
||||
const uploaded = fp.waitForResponse(r => r.url().endsWith('/evidences/accounting_confirmation'));
|
||||
await card.getByLabel('余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting.pdf', mimeType: 'application/pdf',
|
||||
await card.getByLabel('上传余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting.pdf', mimeType: 'application/pdf',
|
||||
buffer: await syntheticBankReceiptPdf({ reference: `SYN-ACCOUNTING-${s.mode}`, amount: '100.00' }) });
|
||||
assert.equal((await uploaded).status(), 200);
|
||||
await card.getByRole('button', { name: '确认期初余额', exact: true }).click();
|
||||
dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间');
|
||||
await expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveCount(0);
|
||||
await expect(card.getByRole('button', { name: '确认借款余额', exact: true })).toBeEnabled();
|
||||
// Display rules against edge states the real list can carry; only this
|
||||
// loan's fields in the list response are altered, saving stays real.
|
||||
const listUrl = (u) => new URL(u).pathname === '/api/company-loans';
|
||||
const asListed = async (patch, check) => {
|
||||
const handler = async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
const response = await route.fetch(); const body = await response.json();
|
||||
for (const item of body.data?.items ?? []) if (item.id === loan.id) patch(item);
|
||||
await route.fulfill({ response, json: body });
|
||||
};
|
||||
await fp.route(listUrl, handler);
|
||||
try { await fp.reload(); await showCard(); await card.locator('summary').click(); await check(); }
|
||||
finally { await fp.unroute(listUrl, handler); }
|
||||
};
|
||||
const confirmButton = card.getByRole('button', { name: '确认借款余额', exact: true });
|
||||
const missingLine = card.locator(`[id="${loan.id}-missing"]`);
|
||||
await asListed((item) => { item.outstanding = '40.00'; }, async () => {
|
||||
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(0); await expect(confirmButton).toBeEnabled();
|
||||
});
|
||||
await asListed((item) => { item.openingOutstandingPrincipal = '80.00'; }, async () => {
|
||||
await expect(card.locator(`[id="${loan.id}-adjustment"]`)).toHaveCount(1);
|
||||
await expect(missingLine).toHaveText('还缺:系统外已还本金依据'); await expect(confirmButton).toBeDisabled();
|
||||
});
|
||||
await asListed((item) => { for (const e of item.evidences) if (e.kind === 'accounting_confirmation') e.governanceStatus = 'quarantined'; }, async () => {
|
||||
await expect(missingLine).toHaveText('还缺:余额核对材料'); await expect(confirmButton).toBeDisabled();
|
||||
await expect(card).toContainText('(未通过检查,不计入)');
|
||||
});
|
||||
await asListed((item) => { item.lenderAcknowledgement = null; }, async () => {
|
||||
await expect(card.locator(`[id="${loan.id}-agreement"]`)).toHaveCount(1);
|
||||
await expect(missingLine).toHaveText('还缺:协议或借款确认书'); await expect(confirmButton).toBeDisabled();
|
||||
await expect(card.getByRole('button', { name: '去出借人确认查看或办理', exact: true })).toBeVisible();
|
||||
});
|
||||
// A failed refresh after an upload must not leave confirmation enabled on the old snapshot.
|
||||
await fp.reload(); await showCard(); await card.locator('summary').click(); await expect(confirmButton).toBeEnabled();
|
||||
const failRefresh = (route) => route.request().method() === 'GET' ? route.fulfill({ status: 500, json: { error: { code: 'SYS_500', message: 'synthetic' } } }) : route.continue();
|
||||
await fp.route(listUrl, failRefresh);
|
||||
try {
|
||||
const again = fp.waitForResponse(r => r.url().endsWith('/evidences/accounting_confirmation'));
|
||||
await card.getByLabel('上传余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting-2.pdf', mimeType: 'application/pdf',
|
||||
buffer: await syntheticBankReceiptPdf({ reference: `SYN-ACCOUNTING-2-${s.mode}`, amount: '100.00' }) });
|
||||
assert.equal((await again).status(), 200);
|
||||
// Wait for the final failure (after the retry), not the in-flight refresh.
|
||||
await expect(fp.getByRole('alert').first()).toBeVisible({ timeout: 20000 });
|
||||
// A failed list hides the cards entirely; either way no confirmation is offered.
|
||||
await expect.poll(async () => !await confirmButton.count() || await confirmButton.isDisabled()).toBe(true);
|
||||
} finally { await fp.unroute(listUrl, failRefresh); }
|
||||
await fp.reload(); await showCard(); await card.locator('summary').click(); await expect(confirmButton).toBeEnabled();
|
||||
checks.push(`${s.mode}_material_slots_follow_registered_balance_validity_statement_and_refresh_state`);
|
||||
await card.getByRole('button', { name: '确认借款余额', exact: true }).click();
|
||||
dialog = fp.getByRole('dialog'); await expect(dialog.getByRole('heading', { name: '确认借款余额', exact: true })).toBeVisible();
|
||||
await expect(dialog).toContainText('示例:公司银行流水');
|
||||
await expect(dialog.getByLabel('余额依据说明')).toHaveValue('');
|
||||
await dialog.getByLabel('余额依据说明').fill('合成公司银行流水,借入日至核对截止日');
|
||||
// With the dialog already open, a reconnect refresh (past the 5 s staleTime)
|
||||
// that is pending, finally fails, or drops the online statement blocks saving
|
||||
// — button and keyboard submit alike — until the live list is good again.
|
||||
let confirmPosts = 0;
|
||||
const countConfirm = (r) => { if (r.url().endsWith(`/${loan.id}/confirm`) && r.method() === 'POST') confirmPosts++; };
|
||||
fp.on('request', countConfirm);
|
||||
const dialogConfirm = dialog.getByRole('button', { name: '确认', exact: true });
|
||||
const blocker = dialog.locator('#loan-confirm-blocker');
|
||||
const reconnect = async () => {
|
||||
await fp.waitForTimeout(5200);
|
||||
await fp.evaluate(() => { window.dispatchEvent(new Event('offline')); window.dispatchEvent(new Event('online')); });
|
||||
};
|
||||
const blockedSubmit = async () => {
|
||||
await expect(dialogConfirm).toBeDisabled(); await dialog.getByLabel('余额依据说明').press('Enter');
|
||||
await fp.waitForTimeout(300); assert.equal(confirmPosts, 0, 'a blocked dialog must not save');
|
||||
};
|
||||
try {
|
||||
let release; const gate = new Promise((resolve) => { release = resolve; });
|
||||
// After a failed assertion the cleanup may unroute first; the late continue is then moot.
|
||||
const held = async (route) => { if (route.request().method() === 'GET') await gate; await route.continue().catch(() => {}); };
|
||||
await fp.route(listUrl, held);
|
||||
try {
|
||||
await reconnect(); await expect(blocker).toHaveText('借款列表正在刷新,请稍候。'); await blockedSubmit();
|
||||
const landed = fp.waitForResponse((r) => listUrl(r.url()) && r.request().method() === 'GET'); release(); await landed;
|
||||
} finally { release(); await fp.unroute(listUrl, held); }
|
||||
await expect(blocker).toHaveCount(0); await expect(dialogConfirm).toBeEnabled();
|
||||
|
||||
await fp.route(listUrl, failRefresh);
|
||||
try {
|
||||
await reconnect(); await expect(blocker).toHaveText('借款列表未能刷新,请关闭后重新打开。', { timeout: 20000 }); await blockedSubmit();
|
||||
} finally { await fp.unroute(listUrl, failRefresh); }
|
||||
await reconnect(); await expect(blocker).toHaveCount(0, { timeout: 20000 }); await expect(dialogConfirm).toBeEnabled();
|
||||
|
||||
const lapsed = async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.continue();
|
||||
const response = await route.fetch(); const body = await response.json();
|
||||
for (const item of body.data?.items ?? []) if (item.id === loan.id) item.lenderAcknowledgement = null;
|
||||
await route.fulfill({ response, json: body });
|
||||
};
|
||||
await fp.route(listUrl, lapsed);
|
||||
try {
|
||||
await reconnect(); await expect(blocker).toHaveText('还缺:协议或借款确认书', { timeout: 20000 }); await blockedSubmit();
|
||||
} finally { await fp.unroute(listUrl, lapsed); }
|
||||
await reconnect(); await expect(blocker).toHaveCount(0, { timeout: 20000 }); await expect(dialogConfirm).toBeEnabled();
|
||||
} finally { fp.off('request', countConfirm); }
|
||||
checks.push(`${s.mode}_open_confirm_dialog_follows_live_refresh_error_and_statement_state`);
|
||||
await Promise.all([fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`) && r.status() === 200),
|
||||
dialog.getByRole('button', { name: '确认', exact: true }).click()]);
|
||||
await expect(dialog).not.toBeVisible(); await expect(card).toContainText('余额已确认');
|
||||
// The failed-refresh case remounted the cards, which collapses the section again.
|
||||
if (!await card.locator('details').evaluate((d) => d.open)) await card.locator('summary').click();
|
||||
await expect(card.getByRole('button', { name: '查看或办理公司代表授权', exact: true })).toBeVisible();
|
||||
checks.push(`${s.mode}_accounting_required_ui_upload_then_balance_confirmation`);
|
||||
|
||||
const beforeRenewal = await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } });
|
||||
|
||||
在新工单中引用
屏蔽一个用户