From c2517292678b57e580c919f4b3964bc7f2f40c48 Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 12 Oct 2026 15:58:10 +0800 Subject: [PATCH] =?UTF-8?q?=E5=80=9F=E6=AC=BE=E6=9D=90=E6=96=99=E5=8C=BA?= =?UTF-8?q?=E6=8C=89=E9=9C=80=E6=98=BE=E7=A4=BA=E5=B9=B6=E5=89=8D=E7=BD=AE?= =?UTF-8?q?=E7=BC=BA=E9=A1=B9=E6=8F=90=E7=A4=BA=EF=BC=8809-17-company-loan?= =?UTF-8?q?-principal-repayment=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 材料区只为仍缺的材料提供上传:原件已存显示可查看,在线确认后收起协议框,历史还款依据仅在登记期初小于原本金时出现 - 余额材料改中性文案(公司银行流水或代账明细账),确认字段改为“余额依据说明”,按钮改为“确认借款余额”并前置列出缺项 - 确认弹窗跟随最新列表状态:刷新中、刷新失败、版本变化、已确认或缺项时禁用按钮与提交 - 浏览器测试覆盖显隐、缺项、材料失效、在线确认失效、刷新与弹窗打开后状态变化;同步操作手册 - 只读复核 P2 已修复关闭;不改后端、接口与数据库 Co-Authored-By: Claude Opus 5.5 --- .../design.md | 12 ++ .../implement.md | 30 ++++ .../prd.md | 29 ++++ client/src/pages/CompanyLoansPage.tsx | 77 ++++++++-- docs/试运行与自动化验证手册.md | 2 +- scripts/qa/company-loan-browser.cjs | 12 +- scripts/qa/company-loan-employee-browser.cjs | 4 +- .../qa/company-loan-lender-ack-browser.cjs | 143 +++++++++++++++--- 8 files changed, 274 insertions(+), 35 deletions(-) diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md index 55bd5eff..db4da8d2 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -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,先补最小可观察实验,再定位并修复;不预判两项都是测试脚本问题。没有迁移、生产连接或新的业务规则。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md index f91a23f5..3585e83e 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -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;未做快照) 用户授权提交、推送并部署,明确不做快照。部署前生产只读核对:重复组合仍为 0 组(出借人 1 条),13 项部署前检查通过;发布来源检查要求候选已推送且工作区干净,故先暂存本记录、以 HEAD=`36b949ec` 推送(`4a5cd8f2..36b949ec`)和部署,部署后再提交记录(提交记录会改变 HEAD、使门禁回执不再匹配)。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md index db53d75b..7b3a56d5 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -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`) 生产只读排查重复组合为 0 组后,用户批准把“同一公司与员工最多一个出借人(归档计入)”落到数据库。结果见 implement 顶部 F 节。 diff --git a/client/src/pages/CompanyLoansPage.tsx b/client/src/pages/CompanyLoansPage.tsx index 653a48c4..94fc6add 100644 --- a/client/src/pages/CompanyLoansPage.tsx +++ b/client/src/pages/CompanyLoansPage.tsx @@ -22,6 +22,20 @@ import { downloadBlob } from "../lib/download"; const evidenceLabels: Record = { 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 = { 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) { - 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 ? : 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 @@ -139,17 +181,24 @@ export function CompanyLoansPage() {

原借入凭据:{loan.originReference}

借款材料与余额确认 - {loan.lenderAcknowledgement ?

已有有效的出借人在线确认,可作为借款确认材料;代账余额核对材料仍需上传。

- :

协议或借款确认书也可由有效的出借人在线确认替代,请在“出借人确认”中发起。

} -
{Object.entries(evidenceLabels).map(([kind, label]) =>
- - {evidenceSlots(loan).map(({ kind, label, note, upload, action }) =>
+ {upload ? :

{label}

} + {note ?

{note}

: null} + {upload ? { 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) => )} + onClick={() => { setEvidence({ loanId: loan.id, id: e.id, name: e.originalName }); open("download"); }}>{e.originalName}{e.governanceStatus === "approved" ? "" : "(未通过检查,不计入)"})}
)}
- {loan.confirmationStatus !== "confirmed" ? : null} + {loan.confirmationStatus !== "confirmed" ? (() => { + const missing = missingEvidence(loan); + return
+ {missing.length ?

还缺:{missing.map((kind) => evidenceLabels[kind]).join("、")}

: null} +
; + })() : loan.lender.employeeId && loan.lender.identityStatus === "verified" + ? : null}
) : null} {tab === "lenders" && lenders.isSuccess ? lenders.data.items.map((lender) =>
@@ -177,9 +226,9 @@ export function CompanyLoansPage() { onClose={() => setMode(null)} onManual={() => open("loan")} onSaved={() => { setMode(null); setTab("loans"); setPage(1); }} /> : null} { if (!opened && !mutation.isPending) setMode(null); }}> - {({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认期初余额", repay: "申请归还本金", download: "下载借款材料" } as Record)[mode ?? ""]} + {({ lender: "登记非员工出借人", account: "更新收款资料", identity: "核实身份关联", loan: "登记借入", confirm: "确认借款余额", repay: "申请归还本金", download: "下载借款材料" } as Record)[mode ?? ""]} {mode === "repay" ? "同一公司和出借人可合并多笔借款。系统按已确认的授权范围确定独立复核或公司代表关联方确认,提交后不能切换模式。" : - mode === "confirm" ? "请根据凭据与代账核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" : + mode === "confirm" ? "请根据凭据与公司银行流水或代账明细核对余额。确认后不能覆盖修改,系统外已还本金须有依据。" : mode === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}
{error ?

{resolveErrorMessage(error)}

: null} @@ -200,7 +249,9 @@ export function CompanyLoansPage() { : null} {mode === "confirm" ? <>

原本金 ¥{selectedLoan?.originalPrincipal},核对日未还 ¥{selectedLoan?.openingOutstandingPrincipal}

- : null} + {confirmBlocker ?

{confirmBlocker}

: null} + +

示例:公司银行流水,借入日至核对截止日,已逐笔核对借入及还款。

: null} {mode === "repay" ? <>

填写已确认借款的分配金额,留空表示本次不归还。翻页保留已填写金额。

@@ -210,7 +261,7 @@ export function CompanyLoansPage() { {mode === "repay" && lenderId ?
第 {candidatePage} 页 · 已填 {Object.values(amounts).filter((v) => v.trim()).length} 笔
: null} {["identity", "repay", "download"].includes(mode ?? "") ? : null} - +
diff --git a/docs/试运行与自动化验证手册.md b/docs/试运行与自动化验证手册.md index e1573dd8..e6ebf92c 100644 --- a/docs/试运行与自动化验证手册.md +++ b/docs/试运行与自动化验证手册.md @@ -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 环境。真实人员确认由相应本人办理;后续生产迁移另行批准,不用合成回执替代真实业务验收。 diff --git a/scripts/qa/company-loan-browser.cjs b/scripts/qa/company-loan-browser.cjs index 929b4eb0..ac616bcc 100644 --- a/scripts/qa/company-loan-browser.cjs +++ b/scripts/qa/company-loan-browser.cjs @@ -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('余额已确认'); diff --git a/scripts/qa/company-loan-employee-browser.cjs b/scripts/qa/company-loan-employee-browser.cjs index 8b1a55ab..8a8312e0 100644 --- a/scripts/qa/company-loan-employee-browser.cjs +++ b/scripts/qa/company-loan-employee-browser.cjs @@ -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(); diff --git a/scripts/qa/company-loan-lender-ack-browser.cjs b/scripts/qa/company-loan-lender-ack-browser.cjs index b1e1822b..ecb2479c 100644 --- a/scripts/qa/company-loan-lender-ack-browser.cjs +++ b/scripts/qa/company-loan-lender-ack-browser.cjs @@ -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 } });