借款材料区按需显示并前置缺项提示(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Waiting to run
ci-minimal / check-minimal (push) Waiting to run

- 材料区只为仍缺的材料提供上传:原件已存显示可查看,在线确认后收起协议框,历史还款依据仅在登记期初小于原本金时出现
- 余额材料改中性文案(公司银行流水或代账明细账),确认字段改为“余额依据说明”,按钮改为“确认借款余额”并前置列出缺项
- 确认弹窗跟随最新列表状态:刷新中、刷新失败、版本变化、已确认或缺项时禁用按钮与提交
- 浏览器测试覆盖显隐、缺项、材料失效、在线确认失效、刷新与弹窗打开后状态变化;同步操作手册
- 只读复核 P2 已修复关闭;不改后端、接口与数据库

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-12 15:58:10 +08:00
共同撰写人 Claude Opus 5.5
父节点 3a4a5969bb
当前提交 c251729267
共修改 8 个文件,包含 274 行新增和 35 行删除
@@ -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:浏览器遗留失败排查设计(已批准实施、产品复核及本地补验完成,未提交) ## 2026-10-10 D:浏览器遗留失败排查设计(已批准实施、产品复核及本地补验完成,未提交)
沿当前借款任务 AC46–AC51,先补最小可观察实验,再定位并修复;不预判两项都是测试脚本问题。没有迁移、生产连接或新的业务规则。 沿当前借款任务 AC46–AC51,先补最小可观察实验,再定位并修复;不预判两项都是测试脚本问题。没有迁移、生产连接或新的业务规则。
@@ -1,3 +1,33 @@
## 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;未做快照) ## 2026-10-11 `36b949ec` 已发布(含迁移 84;未做快照)
用户授权提交、推送并部署,明确不做快照。部署前生产只读核对:重复组合仍为 0 组(出借人 1 条),13 项部署前检查通过;发布来源检查要求候选已推送且工作区干净,故先暂存本记录、以 HEAD=`36b949ec` 推送(`4a5cd8f2..36b949ec`)和部署,部署后再提交记录(提交记录会改变 HEAD、使门禁回执不再匹配)。 用户授权提交、推送并部署,明确不做快照。部署前生产只读核对:重复组合仍为 0 组(出借人 1 条),13 项部署前检查通过;发布来源检查要求候选已推送且工作区干净,故先暂存本记录、以 HEAD=`36b949ec` 推送(`4a5cd8f2..36b949ec`)和部署,部署后再提交记录(提交记录会改变 HEAD、使门禁回执不再匹配)。
@@ -1,5 +1,34 @@
# 公司借款台账与本金归还 # 公司借款台账与本金归还
## 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`) ## 2026-10-11 F:员工与公司组合数据库唯一约束(已发布 `36b949ec`)
生产只读排查重复组合为 0 组后,用户批准把“同一公司与员工最多一个出借人(归档计入)”落到数据库。结果见 implement 顶部 F 节。 生产只读排查重复组合为 0 组后,用户批准把“同一公司与员工最多一个出借人(归档计入)”落到数据库。结果见 implement 顶部 F 节。
+64 -13
查看文件
@@ -22,6 +22,20 @@ import { downloadBlob } from "../lib/download";
const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据", const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据",
accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" }; 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: "登记未完成" }; const stateLabels: Record<string, string> = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" };
type Mode = "lender" | "employeeLender" | "employeeRefresh" | "loanReceipt" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null; 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"; 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); mutation.reset(); setRequestKey(crypto.randomUUID()); setMode(next); setSelectedLender(lender); setSelectedLoan(loan);
setLenderId(lender?.id ?? ""); setAmounts({}); setIdentitySearch(""); setCandidatePage(1); 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>) { 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 text = (key: string) => String(values.get(key) ?? "").trim();
const actorId = me?.id; const actorId = me?.id;
mutation.mutate(async () => { mutation.mutate(async () => {
@@ -113,6 +139,22 @@ export function CompanyLoansPage() {
const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments; const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments;
const tableTab = tab === "loans" || tab === "lenders" || tab === "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; 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 currentPage = tab === "loans" ? page : tab === "lenders" ? lenderPage : repayPage;
const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage; const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage;
return <ListWorkspace> return <ListWorkspace>
@@ -139,17 +181,24 @@ export function CompanyLoansPage() {
</div> </div>
<p className="break-words text-sm">原借入凭据:{loan.originReference}</p> <p className="break-words text-sm">原借入凭据:{loan.originReference}</p>
<details><summary className="min-h-11 cursor-pointer py-3 text-sm font-medium">借款材料与余额确认</summary> <details><summary className="min-h-11 cursor-pointer py-3 text-sm font-medium">借款材料与余额确认</summary>
{loan.lenderAcknowledgement ? <p className="my-2 text-sm">已有有效的出借人在线确认,可作为借款确认材料;代账余额核对材料仍需上传。</p> <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">
: <p className="my-2 text-sm">协议或借款确认书也可由有效的出借人在线确认替代,请在“出借人确认”中发起。</p>} {upload ? <Label htmlFor={`${loan.id}-${kind}`}>{label}</Label> : <p className="text-sm font-medium">{label}</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"> {note ? <p className="mt-1 text-sm text-zinc-600">{note}</p> : null}
<Label htmlFor={`${loan.id}-${kind}`}>{label}</Label> {upload ? <Input id={`${loan.id}-${kind}`} type="file" className="mt-2" accept=".pdf,.png,.jpg,.jpeg,.webp" disabled={mutation.isPending}
<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 = ""; 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" {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> </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> </details>
</section>) : null} </section>) : null}
{tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) => <section key={lender.id} className="card-std space-y-3 p-4"> {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} 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); }}> <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> <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" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" : </DialogTitle><DialogDescription>{mode === "repay" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" :
mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" : mode === "confirm" ? "请根据凭据与公司银行流水或代账明细核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" :
mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader> mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
<form onSubmit={submit} className="grid gap-4"> <form onSubmit={submit} className="grid gap-4">
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null} {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="实际到账日期"><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} <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> {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> {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> {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> <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> {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} <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} {["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> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+1 -1
查看文件
@@ -381,7 +381,7 @@ PROJECT_CLOSURE_CHECKLIST_ENABLED=true
财务发起后,系统只给该出借人创建一条 OA 站内“借款声明待本人确认”通知,不发送企业微信或其他外部消息。出借人使用本人 OA 账号,在电脑或手机的通知中心找到通知(可筛选“借款确认”),点击后直接打开本次声明。普通员工没有常驻“公司借款”入口;财务和公司代表保留原入口。出借人查看明细和原件,勾选本人声明并填写说明后确认;有误则填写原因退回财务。财务不能代确认,普通员工出借人也无需经理权限。历史通知仍可查看,但失效声明须由财务重新发起,不能通过旧通知恢复确认。线上记录作为系统接受的借款确认材料,不因留痕即宣称当然等同纸质签字的法律效力。 财务发起后,系统只给该出借人创建一条 OA 站内“借款声明待本人确认”通知,不发送企业微信或其他外部消息。出借人使用本人 OA 账号,在电脑或手机的通知中心找到通知(可筛选“借款确认”),点击后直接打开本次声明。普通员工没有常驻“公司借款”入口;财务和公司代表保留原入口。出借人查看明细和原件,勾选本人声明并填写说明后确认;有误则填写原因退回财务。财务不能代确认,普通员工出借人也无需经理权限。历史通知仍可查看,但失效声明须由财务重新发起,不能通过旧通知恢复确认。线上记录作为系统接受的借款确认材料,不因留痕即宣称当然等同纸质签字的法律效力。
财务仍须上传代账余额核对材料;截至日未还本金少于原本金时,还须系统外已还本金依据。有效在线确认可替代协议或借款确认书附件,之后由财务填写代账引用并确认期初余额,再单独登记公司代表授权、由有权公司代表确认。出借人声明不自动完成余额确认、授权或付款审批。关键金额、日期、原件或出借人关联变化会使整组旧声明失效,恢复旧值也须重新发起;旧记录保留。已经确认余额的借款,如曾有在线声明且现已失效,仍可重新发起出借人确认,原余额确认记录不被改写。身份资料刷新导致公司代表授权失效时,授权还须另行重新办理。 财务仍须上传余额核对材料(公司银行流水或代账明细账,覆盖从借入到核对截止日;银行流水仅作为附件由财务核对,不是银行流水导入或自动对账);登记的核对日未还本金少于原本金时,还须系统外已还本金依据,此后在 OA 内正常还款不会再要求该依据。有效在线确认可替代协议或借款确认书附件。“借款台账 → 借款材料与余额确认”只对仍缺的材料显示上传,已满足的显示状态并可查看原文件,并在“确认借款余额”按钮上方列出缺项;材料齐全后由财务填写“余额依据说明”(如实注明依据,如“公司银行流水,借入日至核对截止日,已逐笔核对借入及还款”)并确认借款余额,再单独登记公司代表授权、由有权公司代表确认。出借人声明不自动完成余额确认、授权或付款审批。关键金额、日期、原件或出借人关联变化会使整组旧声明失效,恢复旧值也须重新发起;旧记录保留。已经确认余额的借款,如曾有在线声明且现已失效,仍可重新发起出借人确认,原余额确认记录不被改写。身份资料刷新导致公司代表授权失效时,授权还须另行重新办理。
开发验证沿隔离借款 runner,`RUN_ISOLATED_LOAN_ACK_ONLY=1` 选择声明专项,`RUN_ISOLATED_LOAN_BROWSER=1` 加电脑/手机操作;先构建前端,并设置已有 BIFF8 Python 环境。真实人员确认由相应本人办理;后续生产迁移另行批准,不用合成回执替代真实业务验收。 开发验证沿隔离借款 runner,`RUN_ISOLATED_LOAN_ACK_ONLY=1` 选择声明专项,`RUN_ISOLATED_LOAN_BROWSER=1` 加电脑/手机操作;先构建前端,并设置已有 BIFF8 Python 环境。真实人员确认由相应本人办理;后续生产迁移另行批准,不用合成回执替代真实业务验收。
+10 -2
查看文件
@@ -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 }) }); const card = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${loan.loan_no}`, exact: true }) });
await findOnPages(page, card, 'company-loans'); await findOnPages(page, card, 'company-loans');
await card.locator('summary').click(); 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']) { 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'); 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', 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`) }); 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); 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'); dialog = page.getByRole('dialog');
await dialog.getByLabel('代账余额确认引用').fill('合成代账核对'); await dialog.getByLabel('余额依据说明').fill('合成银行流水核对');
await dialog.getByRole('button', { name: '确认', exact: true }).click(); await dialog.getByRole('button', { name: '确认', exact: true }).click();
await expect(dialog).not.toBeVisible(); await expect(dialog).not.toBeVisible();
await expect(card).toContainText('余额已确认'); await expect(card).toContainText('余额已确认');
+2 -2
查看文件
@@ -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`) }); 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); assert.equal((await result).status(), 200);
} }
await loanCard.getByRole('button', { name: '确认期初余额', exact: true }).click(); await loanCard.getByRole('button', { name: '确认借款余额', exact: true }).click();
await page.getByRole('dialog').getByLabel('代账余额确认引用').fill('合成会计往来余额核对记录'); await submit(page); await page.getByRole('dialog').getByLabel('余额依据说明').fill('合成会计往来余额核对记录'); await submit(page);
await expect(loanCard).toContainText('余额已确认'); await expect(loanCard).toContainText('余额已确认');
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click(); await page.getByRole('tab', { name: '公司代表授权', exact: true }).click();
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click(); await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
+126 -17
查看文件
@@ -183,29 +183,138 @@ module.exports = async function lenderAckBrowser(h) {
await fp.reload(); await fp.reload();
const loan = s.loans[0]; 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 }) }); 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 showCard = async () => {
const first = fp.locator('section.card-std').first(); await expect(first).toBeVisible(); for (let i = 0; i < 30 && !await card.count(); i++) {
if (await card.count()) break; const first = fp.locator('section.card-std').first(); await expect(first).toBeVisible();
const oldText = await first.textContent(); const next = fp.getByRole('button', { name: '下一页', exact: true }); if (await card.count()) break;
await expect(next).toBeEnabled(); const oldText = await first.textContent(); const next = fp.getByRole('button', { name: '下一页', exact: true });
await Promise.all([fp.waitForResponse(r => new URL(r.url()).pathname === '/api/company-loans'), next.click()]); await expect(next).toBeEnabled();
await expect(first).not.toHaveText(oldText); 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('合成代账明细科目和期间'); await showCard();
const denied = fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`)); await card.locator('summary').click(); await expect(card).toContainText('出借人已在线确认');
await dialog.getByRole('button', { name: '确认', exact: true }).click(); assert.equal((await denied).status(), 409); // With the original saved and a valid online statement, only the balance
await expect(dialog.getByRole('alert')).toBeVisible(); await dialog.press('Escape'); await expect(dialog).not.toBeVisible(); // 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')); 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' }) }); buffer: await syntheticBankReceiptPdf({ reference: `SYN-ACCOUNTING-${s.mode}`, amount: '100.00' }) });
assert.equal((await uploaded).status(), 200); assert.equal((await uploaded).status(), 200);
await card.getByRole('button', { name: '确认期初余额', exact: true }).click(); await expect(card.locator(`[id="${loan.id}-missing"]`)).toHaveCount(0);
dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间'); 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), await Promise.all([fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`) && r.status() === 200),
dialog.getByRole('button', { name: '确认', exact: true }).click()]); dialog.getByRole('button', { name: '确认', exact: true }).click()]);
await expect(dialog).not.toBeVisible(); await expect(card).toContainText('余额已确认'); 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`); checks.push(`${s.mode}_accounting_required_ui_upload_then_balance_confirmation`);
const beforeRenewal = await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } }); const beforeRenewal = await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } });