借款出借人改为从现有员工选择并复用资料(09-17-company-loan-principal-repayment)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

- 新增员工来源预览、关联与显式刷新接口,受 loan.manage 与员工银行卡权限保护;服务端读取并加密保存,客户端不能另传姓名账号
- 同公司同员工唯一复用,请求键绑定来源;锁序出借人→员工,员工使用 FOR NO KEY UPDATE
- 员工关联出借人禁止手工改账号;在途还款期间禁止刷新,刷新推进版本并使旧公司代表授权失效
- 电脑/手机共用对话框,写请求在卸载或身份切换时取消
- 无新增迁移;本地 126 组 HTTP、45 组浏览器、9 个变异通过,两名只读复核及 Claude 独立复核无阻断

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-09 21:03:07 +08:00
共同撰写人 Claude Opus 5.5
父节点 c1034bbe38
当前提交 45737ccb9a
共修改 20 个文件,包含 1157 行新增和 28 行删除
@@ -1144,7 +1144,10 @@ await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
## Company Loan Principal And Retired ERP Consumers
- `09-17-company-loan-principal-repayment` adds the explicit `loan_repayment` payment source: one retained repayment/batch/item, multiple allocations to confirmed same-company/lender/CNY loans. Preserve original principal and confirmed opening evidence; Decimal reservations and settlements must not exceed opening outstanding principal. SQL checks/deferred triggers complement locked application validation.
- Loan management requires an explicit finance/finance_mgr role plus loan.manage. Independent review requires active manager/CEO with review permission and excludes maker, submitter and lender OA identity; admin wildcard cannot replace these identities. External lenders require explicit verified no-OA identity. Account changes revoke identity confirmation and invalidate old unpaid snapshots.
- Loan management requires an explicit finance/finance_mgr role plus loan.manage. Independent review requires active manager/CEO with review permission and excludes maker, submitter and lender OA identity; admin wildcard cannot replace these identities. External lenders require explicit verified no-OA identity. Manual account changes revoke identity confirmation and invalidate old unpaid snapshots; explicit employee-source refresh renews confirmation under the checks below.
- Employee-source preview/link/refresh additionally require `employee.bank.read` or `employee.bank.manage`. `loanLenderEmployee` reads the active employee, supported bank and encrypted account on the server; the strict write body carries employee/company IDs, source version and an explicit confirmation, never client-supplied account/name replacements. A source version binds actual source fields, ciphertext and the chosen debtor, not only `updated_at`. Missing, unreadable, stale or unsupported sources reject without a partial lender/audit write; previews expose masks only.
- Same-company employee association includes archived/history rows in duplicate checks, including the old identity-confirmation entry. Reuse a unique matching lender; reject ambiguous links and require explicit refresh for different data. Existing-lender actions lock lender before sorted employees. New creation never locks an existing lender after employees: finish and reacquire in the correct order if a link appeared. Use employee `FOR NO KEY UPDATE` to serialize association/source edits while allowing audit/actor FK `KEY SHARE`; `FOR UPDATE` can deadlock against another employee's refresh/audit. Persist and serialize each actor's request-key binding for both creation and reuse, with audit in the same transaction.
- Explicit employee refresh rejects reserved repayments, checks source/identity/account versions and advances both lender versions with renewed finance identity confirmation. This invalidates old company-representative grants without changing principal or historical payment snapshots. Employee-linked lenders cannot use the manual account PATCH; employee-master edits alone never rewrite the loan copy. Verify the actual audit-FK interleaving, duplicate/legacy races, refresh versus repayment creation, audit rollback and reverse mutations using `RUN_ISOLATED_LOAN_EMPLOYEE_ONLY=1` and optional `RUN_ISOLATED_LOAN_EMPLOYEE_MUTATIONS=1` on the isolated loan runner; retain the original full-loan regression.
- Related-party loans use the explicit `loanApprovalPolicy` branch, not a global reviewer exception. Finance proposes `LoanRelatedAuthorization` for one company/lender, its verified employee identity and specific confirmed loans; that designated active manager/CEO confirms it. Grants, scope and lifecycle events are append-only. Resolve the latest confirmed version without falling back to an older grant when the new scope is narrower or stopped; a superseded version cannot receive stop events.
- Freeze `LoanRepayment.approval_mode`, `authorization_id` and `approval_context` at creation. Only `related_party` waives the designated payee-conflict reason. Keep maker/submitter/exporter/uploader/manual-entry/proposer conflicts as a separate union of all history, including voided and superseded materials; do not remove the payee employee from that union. Reject designated-reviewer participation at maker entry points as well as decision/final-consumer checks. Old independent loans and shared reimbursement/travel rules do not switch modes.
- New payment creation/approval/export checks current payment authority; new reconciliation decisions and final reservation release check current reconciliation authority. `decisionFields()` stores purpose, actual actor, target, scope, authorization version/event and conflict identities. `assertDecision()` validates immutable historical provenance at receipt reconstruction, settlement, terminal/recovery consumption and bank-event booking; it does not require the old reviewer to remain active or the old grant to remain enabled. Stopping payment and stopping reconciliation are separate events. Previously approved bank facts remain usable if their evidence and all money/transaction checks still hold.
@@ -40,6 +40,13 @@
actions when false; actual review still checks role, permission and all participants.
Save/review never call payment execution or upload.
## Employee Lender Source Selection
- The default lender action uses `LoanEmployeeLenderDialog` to select an existing employee and confirm the debtor company, name, bank and masked account. Keep employee/company selectors explicitly labelled; do not add duplicate account/name inputs or infer the debtor solely from employment. Missing data directs qualified users to the existing employee-maintenance page; preserve an explicit non-employee route.
- Source query keys include actor, employee and company and forward `AbortSignal`; hide cached summaries without current bank-read permission. Disable background form refetch, clear the confirmation on explicit reload/source changes, and require a separate explicit refresh for differing existing lender data. Show the in-flight block and the need to renew company-representative authority after refresh.
- Both employee link and refresh writes pass the component's abort signal through `saveLoanAction(path, body, method, signal)`. Abort on unmount/identity change as well as checking actor before success callbacks: a callback guard alone cannot prevent a late 401 from replaying the old POST with the new session. This is a scoped fix, not a claim that all older application mutations have this lifecycle.
- Browser acceptance uses the existing isolated loan runner with `RUN_ISOLATED_LOAN_BROWSER=1`: actual desktop/mobile employee selection, historical loan/balance setup and two-person authority confirmation. Test delayed 401 responses across real logout/login in one document, canceled old writes and unchanged lender/audit data; preserve successful same-identity refresh/retry and the original non-employee workflow. Synthetic data and bank tests do not grant production payment authority.
## Source Of Truth
- Reminder cooldown hints render server UTC timestamps in Asia/Shanghai with an
@@ -1,3 +1,18 @@
## 2026-10-09 N5 员工出借人选择方案(已批准,已本地实现)
本节对应 PRD 的 AC28–AC31。员工资料已存在于 `Employee`;现有 `loan-lenders/identity-options` 仅提供身份选择,借款登记尚未复用银行卡。目标是让财务选择现有员工并核对资料,服务端建立或复用借款出借人关系,保持现有贷款、授权和付款快照模型。
1. **选择与资料来源**:在原借款页面增加员工选择流程,复用现有选择框与人员检索样式。返回允许的身份摘要、公司关联、银行与遮罩账号、资料是否完整及来源版本;不向浏览器传完整账号或身份证件。来源为员工主档的当前银行资料,账户名沿现有员工付款规则使用真实姓名。公司关联可作为借款主体默认值,但必须由财务确认借款所属公司,不能把任职公司自动当作债务人。
2. **服务端关联**:新增明确的“从员工关联出借人”动作,只接收员工、借款公司、所见来源版本、核对声明及幂等键。服务端重读员工与银行资料,校验可选身份、公司、银行支持、账号格式/可解密性及资料版本,再沿现有加密方式保存借款侧资料、员工关联和审计。不能通过客户端提供姓名或账号覆盖来源。身份核对由财务此次明确确认完成,不再让同一员工先手填、再另做一次身份绑定。
3. **敏感权限与维护入口**:保留 `loan.manage` 的财务角色条件,并沿用 `employee.bank.read` / `employee.bank.manage` 的现有银行卡读取权限;不借员工选择扩展到完整人员档案、身份证件或银行卡明文查看。资料不全时定位原员工资料维护入口,有维护权限才可修正。姓名、开户行或账号修改不在借款选择弹窗内重复提供。
4. **重复选择及锁序**:同公司同员工存在唯一可用登记时复用;多个历史登记时返回明确冲突,不自动合并借款、账户或授权。新建关联在事务内对来源员工及经办人排序串行,重查重复并保存创建/复用的请求键绑定;刷新已有出借人沿“出借人 → 人员”的顺序锁定,不持人员锁反向锁已有出借人。员工使用 `FOR NO KEY UPDATE`,保留串行保护并兼容审计外键的 `KEY SHARE`。旧身份关联路径也参加同一重复检查;发现并发关系变化时结束事务,按正确锁序重新核对或拒绝,不建立错误关系。
5. **资料变更与历史**:预览至保存间来源变化要求重新核对。已有借款资料与员工主档不同,不静默覆盖;显式“使用当前员工资料”动作由服务端读源、比较版本,沿既有在途禁止修改、账户/身份版本与授权失效规则处理并留审计。已审批或已付批次仍消费原冻结快照。后续员工资料维护不直接重写出借人、付款批次、银行事实或本金。
6. **数据与范围**:优先复用 `employee_id`、出借人版本和既有审计,无需为员工再建一套人员档案。不得为减少手工录入而增加无依据身份、放宽审批排除或恢复 Q6。若实施研究证明必须新增数据库约束或字段,先明确迁移、存量与兼容影响,再扩展本方案;本节不预先授权存量合并、生产迁移或业务录入。
验证包括:资料完整的员工正常关联、缺字段/账号不可用/银行不支持、无银行卡读取权限、客户端伪造、重复及并发选择、预览后资料改变、已有借款资料不一致、在途禁止刷新、员工改号后旧付款快照保持,以及财务/马经理后续授权分工。只用合成数据,不读取真实账户或调用银行/OCR;沿现有测试入口完成针对性验证和只读复核。
界面查询按身份/员工/公司隔离;两个新写动作透传取消信号,随组件卸载或身份变化终止请求,成功回调再次核对身份。不能只隔离回调而让迟到 401 在另一身份下重发原动作;保留同身份的正常令牌刷新。只保护本次入口,不改变共享 HTTP 恢复语义。
## 2026-10-08 实样发现后的凭证处理方案(N1–N2 已本地实现)
本节对应 PRD 顶部 R4/AC5 补充及 implement 的 N1–N2,用户已明确“开始执行”。沿用已实现的两人关联模式、回单存储、人工核对和核销流程;不重新设计 S1–S8/SR,不把 S002 专项格式兼容纳入必修范围。
@@ -1,3 +1,45 @@
## 2026-10-09 N5 员工出借人选择调整(本地实现与验证完成,未提交)
用户提出从现有员工选择出借人并复用资料,避免重复手录。已只读核对员工主档、敏感银行卡维护接口、借款登记/身份/账户接口及电脑手机共用页面;实现基线为本地 `c1034bbe`,线上仍为 `beb9f02c`。用户已明确确认方案,本轮接续本地实现及针对性验证;不连接生产、不代录真实数据,提交、推送和部署不在本轮授权内。
实施顺序:
1. 增加受现有财务和银行卡权限保护的员工资料预览及关联动作;服务端读取姓名、银行和加密账号,版本、重复及并发检查与审计同事务处理。
2. 把员工出借人入口改为选择与确认,移除该路径重复手录;已有登记复用,缺资料指向原员工维护入口;需要使用更新后的员工资料时显式核对并沿既有在途/版本规则刷新。使用既有组件,不引入新视觉体系。
3. 以合成数据完成 AC28–AC31 的正常与拒绝场景、并发和桌面/手机办理流程;资金守卫做必要的针对性变异,并检查旧付款快照及两人关联审批规则保持。
4. 做任务与相关前后端检查、只读复核,按实际能力报告独立复核状态。完成后汇报本地差异及验证,再按授权提交;推送、生产查询、部署和真实台账写入仍各自批准。新候选发布时重新取得完整门禁,不沿用 `beb9f02c` 回执。
范围限于现有员工资料复用与相关借款入口;不改当前报销待办、回单核验口径或生产批次状态。原非员工入口保留,技术员工不参与审批。优先复用现有表;若需要新迁移或存量合并,先补具体影响与批准,不顺手执行。
### 已实现内容
- 新增员工来源预览、关联和显式刷新服务及三个接口;受财务角色、`loan.manage` 和银行卡权限保护。只返回遮罩摘要;客户端不能另传姓名或账号。服务端读源、核对公司与版本,关联真实员工并加密保存借款侧资料及审计,不创建员工、不生成借款或付款。
- 同公司同员工的唯一匹配登记复用;历史重复、归档、旧入口绕过及预览后来源变化均拒绝。复用和创建都保存当前经办人的请求键绑定,防止同一键跨员工或公司使用。已有出借人先锁出借人,再排序锁员工;员工使用 `FOR NO KEY UPDATE`,兼容审计外键的 `KEY SHARE`。
- 员工资料不一致时要求显式“使用当前员工资料”,有在途占用不能刷新。刷新重验身份/账户/来源版本,同时推进身份与账户版本、留存核对审计并使旧公司代表授权失效;原本金及历史付款快照不改。员工关联记录不能再走手工收款账号 PATCH。
- 电脑、手机共用原组件样式:默认员工选择、公司明确核对、遮罩资料、缺项提示及有权限时的维护入口;非员工登记单列保留。资料查询按身份/员工/公司隔离并可取消;两个新写请求在卸载或切换身份时取消,避免迟到 401 以新账号重发。此保护不宣称已覆盖所有旧页面写请求。
- 本轮无新增迁移,仍为 82 项。旧测试为同一员工反复建立同公司出借人,现改为每个独立场景使用独立的合成公司;原业务断言保留。测试错误包装改为正式服务的 `requestId/error` 结构,保留原拒绝断言并验证实际页面错误提示。
### 独立只读复核与修复
本次复用了财务与界面两名只读复核代理,均不改文件、不重跑测试、不连接生产;本轮结论不借用 N3 的历史复核。两者接收修复与专项回执后均为 **REVIEWED_NO_BLOCKER**。
| 发现 | 修复及见证 |
| --- | --- |
| 财务 P2:复用未绑定请求键,旧键可能用于另一个员工 | 创建/复用的审计均保存来源及请求键,按经办人串行;顺序、并发与移除绑定的反例通过。 |
| 财务 P2:员工 `FOR UPDATE` 与审计员工外键形成锁循环 | 改为 `FOR NO KEY UPDATE`;实际 HTTP 关联和刷新经数据库 gate 确定性交错,两请求均完成;恢复旧锁时观测 PostgreSQL 死锁计数增长。 |
| 界面 P1:仅隔离回调不能阻止旧 POST 在切换身份后因 401 被重发 | 两个写动作透传并在卸载/身份变化时终止 `AbortSignal`;同一浏览器文档中真实退出/登录另一财务,延迟旧 401,验证取消、无重发、无错误写入/审计,同时验证同身份自动刷新仍成功。 |
### 验证回执
证据根目录:`artifacts/qa-flow/loan-employee-source-20261009/`,均为本地合成数据。
- `mutations-r6.log`:82 迁移、10 组员工 HTTP、9 个针对性变异、资源清理全部 PASS。变异覆盖银行卡权限、来源版本、旧身份重复入口、员工串行锁、外键锁兼容、在途占用、出借人版本、复用请求键及必要审计;普通超时或环境失败不能算捕获。
- `browser-r3.log`:10 组员工 HTTP 和 8 组电脑/手机浏览器场景 PASS,包括历史借入、余额核对、财务登记授权及指定经理确认;关联/刷新同身份 401 恢复、跨身份迟到请求取消均实际执行。截图在 `browser-LWICsl/`;横向溢出检查和主会话截图走查通过。
- `unit-r1.log`:本轮针对授权策略和错误消息合同的 2 文件、948 项通过;其输入未改变,后续复用。`frontend-build-r5.log` 的类型检查及构建、Prisma 校验、错误码合同、UI 规则和差异检查通过。未运行本次服务端全量或完整发布门禁。
- 完整借款 HTTP/浏览器回归:`regression-r2.log`、`regression-r2-process.json` 和去重汇总 `regression-r2-summary.json` 为 PASS,退出 0;82 迁移、原有 116 组 HTTP 加新增 10 组(共 126 组)、45 组浏览器、9 组基础数据库检查及资源清理通过。45 组已包含关联模式的 18 组和新入口的 8 组,不重复累加。首轮收到 SIGTERM(退出 143),没有最终回执,不能算通过;信号来源未证实,原日志和 `regression-r1-interruption.json` 保留,已按 owner 标签核对并只清理该轮隔离容器/临时目录。第二轮使用同一命令和断言在独立进程执行,不缩小范围、不增加用例重试。
- 较早失败记录保留:首轮账号解密返回空值的分类问题已修;前两轮浏览器分别暴露选择框可访问名称和测试错误包装问题,已修并完整重跑专项;`mutations-r3`–`r5` 的合成交错类型/顺序及捕获分类未通过,不计入通过次数,最终以 r6 为准。
AC28–AC31 的本地实现及验收已完成,最终文档治理结果见本目录 `final-checks.json`。文档索引保留既有 22 份未登记文件的警告,无新增错误,不在本轮整理无关文档。N5 真实台账/公司授权登记和 N6 首笔真实还款仍待办理,整体任务保持 `in_progress`;没有提交、推送、部署、访问生产、调用真实 OCR 或代录真实资料。线上仍为 `beb9f02c`;新候选以后发布须重新跑完整门禁。
## 2026-10-09 N4 发布结果与 N5/N6 接续
本节记录已完成的发布,候选为 `beb9f02c19970aa452c3fac02debfb10db0d4a3d`。Claude 会话执行发布及核对、由用户转交结果;主会话另核对本地候选、只读汇总、部署与补验日志、独立复核记录及生产 PDF 冒烟回执。本轮只更新现有任务文档,没有重新连接生产、执行付款或改动程序;整体任务仍为 `in_progress`。
@@ -1,5 +1,16 @@
# 公司借款台账与本金归还
## 2026-10-09 N5 员工出借人选择调整(本地验证完成,未发布)
用户要求员工出借人从已有员工中选择,复用系统中已维护的资料,不重复录入姓名和银行卡。已核对:员工主档存在姓名、公司关联、银行机构及加密账号;原借款入口要求手填,再另行核实员工关联。用户已确认本方案,授权本地实现与验证;不包含提交、推送、部署或真实业务录入。
- 员工出借人从已有 OA 员工选择,财务核对姓名、所属公司、开户行和遮罩账号后关联;姓名与银行资料由服务端读取,不接受浏览器另传一套值,也不创建新员工。借款所属公司仍须明确确认。
- 沿用现有员工资料和银行卡维护权限;资料缺失、不可用或不符合现行付款条件时,说明缺项并引导有权限的人到员工资料维护,不在借款表单重复填写或自行替换真实开户行。
- 同公司同员工已有出借人登记时优先复用;历史重复或资料不一致必须显式处理,不按姓名合并。员工资料更新不自动改写借款授权或在途付款;复用后的账户刷新按既有版本、占用和审计规则核对。
- 仅调整员工出借人的资料入口;原非员工个人出借人能力保留,关联方审批、技术员工不参与、Q6 禁用和真实付款授权边界不变。具体实现及验证范围见 design、implement 顶部。
AC28–AC31 已本地实现并通过独立只读复核:126 组借款 HTTP、45 组电脑/手机浏览器、82 迁移、9 个针对性变异及相关类型/构建/合同检查通过;实际范围、前期失败和完整重跑证据见 implement。未提交、未发布,也未录入真实台账;不把本次软件验收算作 N5 真实登记或 N6 银行还款闭环完成。
## 2026-10-09 N4 发布完成,真实业务验收待办
已接收并核对本次发布证据:`beb9f02c` 完整门禁 PASS,独立只读代码复核无阻断项;发布前只读核实借款相关存量均为 0、实际待应用迁移为 9 个。该候选已推送并部署,部署后 82/82 迁移全部应用、13/13 复核及合同刷新通过;公网健康、就绪、浏览器页面和静态资源检查正常(Claude 会话核对、用户转交);部署前按用户决定未做快照。生产合成 PDF 解析冒烟 PASS,运行版本与候选一致,识别为正式支出回单、PDF 文字、明确成功、无问题标记,耗时 521 毫秒。发布中断与补验过程、证据位置及核验边界见 implement 顶部。
@@ -181,6 +192,11 @@ R3 的模式同时作用于付款审批、提交清单、成功/失败归属、
- [x] AC26(页面与访问):电脑和手机以现有组件走通两人流程;财务无自审按钮,经理无经办按钮,必要材料按本批范围及用途审计读取;技术员工不获候选、待办、通知或材料。前端隐藏不能替代服务端鉴权。
- [x] AC27(数据与回退):仅新增型迁移,历史记录明确归为独立模式且不伪造授权;新字段、外键、模式/关联身份及不可变约束在数据库验收。新增关联决定后禁止不经评估降回不理解该模式的旧代码;最终候选重新验证并按规则只读复核,不沿用旧门禁宣称通过。数据库合成验收、独立代码复核及 `beb9f02c` 完整门禁已取得,生产已应用 82/82 迁移。
- [x] AC28(员工选择,本地验收完成):财务可从现有员工选择出借人并使用已有姓名、银行和账号,直接关联真实员工身份;不新建员工、不要求再次录入同一资料,列表与确认页只显示遮罩账号。借款所属公司明确,非员工路径不隐瞒已有员工身份。
- [x] AC29(来源与权限,本地验收完成):只有现有合格财务可以选择并关联;银行卡资料权限继续校验,不扩大员工或技术人员权限。缺资料、不可用银行或账号、越权及伪造客户端姓名/账户均拒绝;资料只在原员工维护入口补正。
- [x] AC30(重复与版本,本地验收完成):重复或并发选择同公司同员工复用明确的已有登记,不产生新员工或无声合并历史记录;预览至确认间来源变更时要求重新核对。已有出借人刷新保持既有锁序、版本、在途限制和审计;员工主档变化不改写已批准付款、已发生银行事实或本金。
- [x] AC31(办理验证,本地验收完成):合成数据贯通选择员工、登记历史借入、确认余额、登记公司授权及经理确认;电脑/手机入口和缺资料提示可用。验证来源变更、并发重复、无权限和旧快照保持的反例,不用真实员工资料或付款作为开发测试。
## Out of Scope
- 不处理利息、工资/分红、员工向公司借备用金、资本金、债转股、机构借款、外币、一般退款或债务冲销;仅保留本任务已定义的超额还款实际追回,不做冲抵后续本金。
@@ -194,6 +210,8 @@ R3 的模式同时作用于付款审批、提交清单、成功/失败归属、
## Current Status
N5 办理前新增“员工出借人从已有员工选择并复用资料”的调整要求,方案已列于本任务顶部,尚待批准实施。线上仍为原登记入口;本轮没有真实业务登记,也不重复索取已确认的借款事实。
当前线上及 `origin/main` 为 `beb9f02c`;N3 独立复核和 N4 本次发布检查已完成,实际发布过程及证据见 implement 顶部。完整门禁为 82 迁移、服务端 4558 通过/1 项既有可选跳过、浏览器 372 全通过且零失败/跳过/重试;生产迁移、部署后复核、公网基础检查及合成 PDF 解析已核对。本轮只更新任务文档,未重跑应用测试或操作生产。
整体任务保持 `in_progress`。真实 OA 台账及关联授权尚未登记,首笔真实还款的网银提交、正式回单、核销及会计交接尚未验收;AC5 及未覆盖的真实材料边界继续开放。无需重新索取已确认的借款事实或要求技术员工参与;真实录入和付款仍按各自授权执行,不从未还本金推定本次全额支付。
+14 -3
查看文件
@@ -53,10 +53,21 @@ export function fetchLoanRepayments(page: number, signal?: AbortSignal) {
return apiRequestData<{ items: LoanRepayment[]; total: number }>(`/api/loan-repayments?page=${page}&pageSize=30`, { signal });
}
export function fetchLoanIdentities(q: string, signal?: AbortSignal) {
return apiRequestData<{ items: Array<{ id: string; name: string; employeeNo: string }> }>(`/api/loan-lenders/identity-options?q=${encodeURIComponent(q)}`, { signal });
return apiRequestData<{ items: Array<{ id: string; name: string; employeeNo: string; legalEntityId: string | null }> }>(`/api/loan-lenders/identity-options?q=${encodeURIComponent(q)}`, { signal });
}
export function saveLoanAction<T>(path: string, body: unknown, method = "POST") {
return apiRequestData<T>(`/api${path}`, { method, body: JSON.stringify(body) });
export type LoanEmployeeSource = {
employee: { id: string; name: string; employeeNo: string | null; legalEntityId: string | null; legalEntityName: string | null };
legalEntityId: string; legalEntityName: string; sourceVersion: string;
bankName: string; bankAccountMasked: string | null; problems: string[];
linkState: "new" | "reusable" | "refresh_required" | "conflict";
existingLender: LoanLender | null;
};
export function fetchLoanEmployeeSource(employeeId: string, legalEntityId: string, signal?: AbortSignal) {
const query = new URLSearchParams({ legalEntityId });
return apiRequestData<LoanEmployeeSource>(`/api/loan-lenders/employee-source/${encodeURIComponent(employeeId)}?${query}`, { signal });
}
export function saveLoanAction<T>(path: string, body: unknown, method = "POST", signal?: AbortSignal) {
return apiRequestData<T>(`/api${path}`, { method, body: JSON.stringify(body), signal });
}
export function createLoanRepayment(body: { requestKey: string; lenderId: string; reason: string;
allocations: Array<{ loanId: string; amount: string }> }) {
+5
查看文件
@@ -1,4 +1,9 @@
export const ERROR_REASON_MESSAGES: Record<string, string> = {
"loan.employee_source_incomplete": "员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。",
"loan.employee_source_changed": "员工资料已变化,请重新加载并核对后再保存。",
"loan.employee_lender_conflict": "该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。",
"loan.employee_lender_refresh_required": "已有出借人资料与当前员工资料不同,请核对后使用“使用当前员工资料”更新。",
"loan.employee_account_managed": "员工出借人的收款资料来自员工档案,请先维护员工资料,再使用“使用当前员工资料”更新。",
"loan.related_authorization_required": "本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。",
"loan.related_scope_invalid": "关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。",
"loan.related_actor_conflict": "关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。",
@@ -0,0 +1,123 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { useAuth } from "../../auth/AuthContext";
import { hasPermission } from "../../auth/permissions";
import { fetchLegalEntities } from "../../api/masterData";
import { fetchLoanEmployeeSource, fetchLoanIdentities, saveLoanAction, type LoanLender } from "../../api/companyLoans";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
const selectClass = "min-h-11 min-w-0 w-full rounded-md border border-zinc-300 bg-white px-3 text-sm";
const problemLabels: Record<string, string> = {
name_invalid: "员工姓名未完整维护", bank_missing: "银行或开户行未完整维护", bank_unsupported: "当前银行不支持借款还本或已停用",
account_missing: "员工收款账号未维护", account_invalid: "员工收款账号格式不符合要求", account_unavailable: "员工收款账号暂不可读取",
};
export function LoanEmployeeLenderDialog({ lender, onClose, onExternal, onRefresh, onSaved }: {
lender: LoanLender | null; onClose: () => void; onExternal: () => void;
onRefresh: (lender: LoanLender) => void; onSaved: (lender: LoanLender) => void;
}) {
const { me, permissions } = useAuth();
const cache = useQueryClient();
const [search, setSearch] = useState("");
const [employeeId, setEmployeeId] = useState(lender?.employeeId ?? "");
const [companyId, setCompanyId] = useState(lender?.legalEntityId ?? "");
const [companyChosen, setCompanyChosen] = useState(Boolean(lender));
const [checked, setChecked] = useState(false);
const [reason, setReason] = useState("");
const [requestKey] = useState(() => crypto.randomUUID());
const alive = useRef(true);
const pendingRequest = useRef<AbortController | null>(null);
const actor = useRef(me?.id); actor.current = me?.id;
useEffect(() => {
alive.current = true;
return () => { alive.current = false; pendingRequest.current?.abort(); };
}, [me?.id]);
const canReadBank = hasPermission(permissions, "employee.bank.read") || hasPermission(permissions, "employee.bank.manage");
const canMaintain = hasPermission(permissions, "employee.bank.manage") && hasPermission(permissions, "system.users");
const companies = useQuery({ queryKey: ["loan-companies", me?.id], queryFn: () => fetchLegalEntities({ enabled: true }), enabled: canReadBank && !lender });
const people = useQuery({ queryKey: ["loan-identities", me?.id, search], queryFn: ({ signal }) => fetchLoanIdentities(search, signal), enabled: canReadBank && !lender });
const source = useQuery({ queryKey: ["loan-employee-source", me?.id, employeeId, companyId],
queryFn: ({ signal }) => fetchLoanEmployeeSource(employeeId, companyId, signal),
enabled: canReadBank && Boolean(employeeId && companyId), refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: 0 });
useEffect(() => { setChecked(false); }, [employeeId, companyId, source.dataUpdatedAt]);
const data = canReadBank && source.isSuccess ? source.data : undefined;
const existing = data?.existingLender;
const blocked = !canReadBank || !data || source.isFetching || data.problems.length > 0 || data.linkState === "conflict" ||
(lender ? existing?.id !== lender.id || existing.hasInFlightRepayment : data.linkState === "refresh_required");
const mutation = useMutation({ mutationFn: async () => {
if (blocked || !checked || !data) throw new Error("请先重新加载并核对员工资料。");
const actorId = me?.id;
const controller = new AbortController(); pendingRequest.current = controller;
try {
const result = lender && existing ? await saveLoanAction<LoanLender>(`/loan-lenders/${lender.id}/employee-refresh`, {
expectedSourceVersion: data.sourceVersion, expectedIdentityVersion: existing.identityVersion,
expectedAccountVersion: existing.accountVersion, detailsConfirmed: true, reason: reason.trim(),
}, "POST", controller.signal) : (await saveLoanAction<{ lender: LoanLender; reused: boolean }>("/loan-lenders/from-employee", {
requestKey, employeeId, legalEntityId: companyId, expectedSourceVersion: data.sourceVersion, detailsConfirmed: true,
}, "POST", controller.signal)).lender;
if (alive.current && actor.current === actorId) {
await Promise.all(["loan-lenders", "company-loans", "loan-related-authorizations", "loan-employee-source"]
.map((key) => cache.invalidateQueries({ queryKey: [key, actorId] })));
if (alive.current && actor.current === actorId) onSaved(result);
}
} finally { if (pendingRequest.current === controller) pendingRequest.current = null; }
} });
const error = mutation.error ?? source.error ?? (!lender ? people.error ?? companies.error : null);
function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); mutation.mutate(); }
function chooseEmployee(id: string) {
setEmployeeId(id); setChecked(false); mutation.reset();
if (!companyChosen) setCompanyId(people.data?.items.find((person) => person.id === id)?.legalEntityId ?? "");
}
return <Dialog open onOpenChange={(open) => { if (!open && !mutation.isPending) onClose(); }}>
<DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader>
<DialogTitle>{lender ? "使用当前员工资料" : "从员工选择出借人"}</DialogTitle>
<DialogDescription>{lender ? "更新出借人的姓名与收款资料后,原公司代表授权须重新登记确认。已经审批和付款的历史记录保留原资料。" : "选择已有员工,核对姓名和收款资料后关联。借款所属公司须单独确认。"}</DialogDescription>
</DialogHeader>
{!canReadBank ? <p role="alert" className="text-sm">当前账号没有员工银行卡读取权限,请由具有该权限的财务办理。</p> : null}
<form onSubmit={submit} className="grid gap-4">
<fieldset disabled={mutation.isPending} className="grid min-w-0 gap-4">
{!lender && canReadBank ? <>
<label className="grid gap-1.5 text-sm">检索员工<Input value={search} onChange={(e) => { setSearch(e.target.value); chooseEmployee(""); }} placeholder="姓名或员工编号" maxLength={100} /></label>
<label className="grid gap-1.5 text-sm">已有员工<select aria-label="已有员工" required className={selectClass} value={employeeId} onChange={(e) => chooseEmployee(e.target.value)} disabled={!people.isSuccess || people.isFetching}>
<option value="">请选择已有员工</option>{people.data?.items.map((person) => <option key={person.id} value={person.id}>{person.name} · {person.employeeNo || "未编员工号"}</option>)}
</select></label>
{people.isSuccess && !people.data.items.length ? <p className="text-sm">未找到可选择的员工,请核对姓名或员工编号。</p> : null}
<label className="grid gap-1.5 text-sm">借款所属公司<select aria-label="借款所属公司" required className={selectClass} value={companyId} disabled={!companies.isSuccess} onChange={(e) => { setCompanyId(e.target.value); setCompanyChosen(true); setChecked(false); mutation.reset(); }}>
<option value="">请选择并核对借款所属公司</option>{companies.data?.items.filter((company) => company.currencyCode === "CNY").map((company) => <option key={company.id} value={company.id}>{company.name}</option>)}
</select></label>
<p className="text-sm text-zinc-600">公司默认值来自员工所属公司,请以实际借款关系核对。</p>
</> : null}
{source.isFetching ? <p role="status" className="text-sm">正在读取员工资料…</p> : null}
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{data ? <>
<dl className="grid gap-2 rounded border p-3 text-sm" aria-label="员工资料摘要">
<div><dt className="text-zinc-600">姓名及账户名</dt><dd className="break-words">{data.employee.name || "待维护"}</dd></div>
<div><dt className="text-zinc-600">员工所属公司</dt><dd className="break-words">{data.employee.legalEntityName ?? "未维护"}</dd></div>
<div><dt className="text-zinc-600">借款所属公司</dt><dd className="break-words">{data.legalEntityName}</dd></div>
<div><dt className="text-zinc-600">开户行</dt><dd className="break-words">{data.bankName || "待维护"}</dd></div>
<div><dt className="text-zinc-600">收款账号</dt><dd className="break-all">{data.bankAccountMasked ?? "待维护或核对"}</dd></div>
</dl>
{data.problems.length ? <p role="alert" className="text-sm text-amber-800">{data.problems.map((problem) => problemLabels[problem] ?? "员工资料待核对").join(";")}。请在员工资料中维护后重新加载。</p> : null}
{canMaintain ? <Link to={`/people/${encodeURIComponent(employeeId)}`} className="inline-flex min-h-11 items-center text-sm text-primary underline">维护员工资料</Link> : data.problems.length ? <p className="text-sm">请具有员工银行卡维护权限的人在员工档案中修正。</p> : null}
{data.linkState === "conflict" ? <p role="alert" className="text-sm text-amber-800">本公司已有重复、归档或已变化的关联,请核对原出借人记录,不能自动合并。</p> : null}
{!lender && data.linkState === "reusable" ? <p className="text-sm">该员工在本公司已有出借人登记,将使用已有记录。</p> : null}
{existing && (lender || data.linkState === "refresh_required") ? <div className="grid gap-2 rounded border p-3 text-sm">
<p className="break-words">已有出借人资料:{existing.name} · {existing.bankName} · {existing.bankAccountMasked ?? "账号暂不可用"}</p>
{existing.hasInFlightRepayment ? <p role="alert">该出借人有还款正在办理,处理完成或安全取消后才能更新资料。</p> : null}
{!lender ? <><p>已有资料与当前员工资料不同,请核对后显式更新。</p><Button type="button" variant="outline" disabled={existing.hasInFlightRepayment || Boolean(data.problems.length)} onClick={() => onRefresh(existing)}>使用当前员工资料</Button></> : null}
</div> : null}
<Button type="button" variant="outline" disabled={source.isFetching} onClick={() => { setChecked(false); mutation.reset(); void source.refetch(); }}>重新加载员工资料</Button>
</> : null}
{lender ? <label className="grid gap-1.5 text-sm">更新依据<Input required minLength={2} maxLength={500} value={reason} onChange={(e) => setReason(e.target.value)} /></label> : null}
<label className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" required disabled={blocked} checked={checked} onChange={(e) => setChecked(e.target.checked)} /><span>已核对借款所属公司、员工身份和收款资料</span></label>
<Button type="submit" disabled={blocked || !checked || mutation.isPending}>{mutation.isPending ? "处理中…" : lender ? "确认更新出借人资料" : data?.linkState === "reusable" ? "使用已有出借人" : "确认关联员工"}</Button>
{!lender ? <Button type="button" variant="outline" onClick={onExternal}>登记非员工出借人</Button> : null}
</fieldset>
</form>
</DialogContent>
</Dialog>;
}
+11 -6
查看文件
@@ -8,6 +8,7 @@ import { fetchBankInstitutions } from "../api/paymentAccounts";
import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoanIdentities, fetchLoanLenders,
fetchLoanRepayments, saveLoanAction, uploadLoanEvidence, type CompanyLoan, type LoanLender } from "../api/companyLoans";
import { LoanRelatedAuthorizations } from "../components/common/LoanRelatedAuthorizations";
import { LoanEmployeeLenderDialog } from "../components/common/LoanEmployeeLenderDialog";
import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -20,7 +21,7 @@ import { downloadBlob } from "../lib/download";
const evidenceLabels: Record<string, string> = { agreement: "协议或借款确认书", incoming: "原借入凭据",
accounting_confirmation: "余额核对材料", adjustment: "系统外已还本金依据" };
const stateLabels: Record<string, string> = { draft: "待付款复核", reviewed: "待付款", executed: "已付款", cancelled: "已取消", failed: "登记未完成" };
type Mode = "lender" | "identity" | "account" | "loan" | "confirm" | "repay" | "download" | null;
type Mode = "lender" | "employeeLender" | "employeeRefresh" | "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";
function Field({ label, children }: { label: string; children: ReactNode }) {
const id = useId();
@@ -103,7 +104,7 @@ export function CompanyLoansPage() {
const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage;
return <ListWorkspace>
<WorkspaceHeader title="公司借款" subtitle="记录公司向个人借入及归还的人民币本金。余额以核对截止日及 OA 登记为依据,未核实不代表已结清。"
actions={<HeaderActionGroup><Button variant="outline" onClick={() => open("lender")}>登记出借人</Button>
actions={<HeaderActionGroup><Button variant="outline" onClick={() => open("employeeLender")}>登记出借人</Button>
<Button onClick={() => open("loan")}>登记借入</Button><Button variant="outline" onClick={() => open("repay")}>申请还本</Button></HeaderActionGroup>} />
<div className="flex flex-wrap gap-2" role="tablist" aria-label="借款台账视图">
{([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) =>
@@ -141,7 +142,7 @@ export function CompanyLoansPage() {
<div className="flex flex-wrap gap-2"><Button variant="outline" disabled={lender.hasInFlightRepayment}
aria-describedby={lender.hasInFlightRepayment ? `${lender.id}-in-flight` : undefined} onClick={() => open("identity", lender)}>核实身份关联</Button>
<Button variant="outline" disabled={lender.hasInFlightRepayment}
aria-describedby={lender.hasInFlightRepayment ? `${lender.id}-in-flight` : undefined} onClick={() => open("account", lender)}>更新收款资料</Button></div>
aria-describedby={lender.hasInFlightRepayment ? `${lender.id}-in-flight` : undefined} onClick={() => open(lender.employeeId ? "employeeRefresh" : "account", lender)}>{lender.employeeId ? "使用当前员工资料" : "更新收款资料"}</Button></div>
</section>) : null}
{tab === "repayments" && repayments.isSuccess ? repayments.data.items.map((repayment) => <section key={repayment.id} className="card-std space-y-2 p-4">
<h2 className="font-semibold break-all">{repayment.lenderName} · {repayment.repaymentNo}</h2>
@@ -152,15 +153,19 @@ export function CompanyLoansPage() {
</section>) : null}
{tab !== "authorizations" ? <div className="flex items-center gap-3"><Button variant="outline" disabled={currentPage <= 1} onClick={() => setCurrentPage(currentPage - 1)}>上一页</Button>
<span>第 {currentPage} 页</span><Button variant="outline" disabled={!current.data || currentPage * 30 >= current.data.total} onClick={() => setCurrentPage(currentPage + 1)}>下一页</Button></div> : null}
<Dialog open={mode !== null} onOpenChange={(opened) => { if (!opened && !mutation.isPending) setMode(null); }}>
{mode === "employeeLender" || mode === "employeeRefresh" ? <LoanEmployeeLenderDialog key={`${me?.id}-${requestKey}`}
lender={mode === "employeeRefresh" ? selectedLender : null} onClose={() => setMode(null)} onExternal={() => open("lender")}
onRefresh={(lender) => open("employeeRefresh", lender)} onSaved={() => { setMode(null); setTab("lenders"); setLenderPage(1); }} /> : null}
<Dialog open={mode !== null && mode !== "employeeLender" && mode !== "employeeRefresh"} 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 === "account" ? "更新后须重新核实身份关联。该出借人有还款正在办理时不能更新,须待处理完成或安全取消后再操作。" : "仅录入已核对的业务事实;敏感材料仅供有权人员查阅。"}</DialogDescription></DialogHeader>
<form onSubmit={submit} className="grid gap-4">
{error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(error)}</p> : null}
{mode === "lender" ? <><Field label="公司主体"><select required name="company" className={selectClass}><option value="">请选择</option>{companies.data?.items.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
{mode === "lender" ? <><p className="text-sm text-zinc-600">仅用于非员工个人出借人;已有员工请从员工资料中选择。</p><Button type="button" variant="outline" onClick={() => open("employeeLender")}>从已有员工选择</Button>
<Field label="公司主体"><select required name="company" className={selectClass}><option value="">请选择</option>{companies.data?.items.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}</select></Field>
<Field label="出借人姓名"><Input name="name" required maxLength={120} /></Field></> : null}
{mode === "lender" || mode === "account" ? <><Field label="收款银行"><select name="bank" required className={selectClass} defaultValue={selectedLender?.bankInstitutionId ?? ""}><option value="">请选择</option>{banks.data?.items.filter((b) => b.code === "CCB").map((b) => <option key={b.id} value={b.id}>{b.name}</option>)}</select></Field>
<Field label="开户行名称"><Input name="bankName" required maxLength={200} defaultValue={selectedLender?.bankName} /></Field>
+3 -1
查看文件
@@ -6,7 +6,7 @@ const root = path.resolve(__dirname, '../..');
const req = createRequire(path.join(root, 'client/package.json'));
const { chromium, expect } = req('@playwright/test');
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi, config, relatedParty }) {
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi, config, relatedParty, employeeSources }) {
const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-'));
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
@@ -39,6 +39,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
await page.getByRole('button', { name: '登记出借人', exact: true }).click();
let dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: '登记非员工出借人', exact: true }).click();
await dialog.getByLabel('公司主体').selectOption(company.id);
const lenderName = `合成浏览器出借人-${mode}`;
await dialog.getByLabel('出借人姓名').fill(lenderName);
@@ -301,6 +302,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
browser, origin, output, finance, manager, password, prisma, fixtures: reconciliationUi,
}));
if (relatedParty) checks.push(...await require('./company-loan-related-browser.cjs')({ browser, origin, output, finance, manager, ordinary, password, prisma, config, relatedParty }));
if (employeeSources) checks.push(...await require('./company-loan-employee-browser.cjs')({ browser, origin, output, finance, password, prisma, config, employeeSources }));
const context = await browser.newContext({ serviceWorkers: 'block' });
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
@@ -0,0 +1,250 @@
const assert = require('node:assert/strict');
const path = require('node:path');
const { mkdtempSync, mkdirSync } = require('node:fs');
const { createRequire } = require('node:module');
const root = path.resolve(__dirname, '../..');
const clientRequire = createRequire(path.join(root, 'client/package.json'));
const serverRequire = createRequire(path.join(root, 'server/package.json'));
const { chromium, expect } = clientRequire('@playwright/test');
module.exports = async function employeeBrowser(h) {
const { finance, password, prisma, config, employeeSources } = h;
const own = !h.browser;
const browser = h.browser ?? await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
const origin = h.origin ?? await h.app.listen({ host: '127.0.0.1', port: 0 });
const base = path.join(root, 'artifacts/qa-flow/loan-employee-source-20261009'); mkdirSync(base, { recursive: true });
const output = h.output ?? mkdtempSync(path.join(base, 'browser-'));
const oldOrigin = config.appOrigin; config.appOrigin = origin;
const checks = [];
const { encryptStringOrThrow } = serverRequire('./src/services/crypto');
try {
for (const s of employeeSources.ui) {
const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : '';
const contexts = [];
async function login(who) {
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
contexts.push(context); await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
const page = await context.newPage();
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${prefix}/company-loans`)}`);
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click(); await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
return page;
}
async function submit(page, label = '确认', expected = 200) {
const pending = page.waitForResponse(r => r.url().startsWith(`${origin}/api/`) && !r.url().includes('/auth/') && r.request().method() === 'POST');
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click();
const response = await pending;
assert.equal(response.status(), expected, `employee ${s.mode} ${label}: ${response.status()}`);
if (expected === 200) await expect(page.getByRole('dialog')).not.toBeVisible();
return response;
}
async function submitAfterSameActor401(page, label, endpoint) {
let attempts = 0;
const handler = async route => {
attempts++;
if (attempts === 1) return route.fulfill({ status: 401, contentType: 'application/json',
body: JSON.stringify({ requestId: 'synthetic-expired-access', error: { code: 'AUTH_001', message: '合成过期令牌' } }) });
return route.continue();
};
await page.route(`**${endpoint}`, handler);
try {
const final = page.waitForResponse(r => new URL(r.url()).pathname === endpoint && r.request().method() === 'POST' && r.status() === 200);
await page.getByRole('dialog').getByRole('button', { name: label, exact: true }).click();
const response = await final;
assert.equal(attempts, 2, 'same-actor expiry keeps the normal single refresh retry');
await expect(page.getByRole('dialog')).not.toBeVisible();
return response;
} finally { await page.unroute(`**${endpoint}`, handler); }
}
async function findCard(page, card) {
for (let i = 0; i < 20; i++) {
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
if (await card.count()) return;
const next = page.getByRole('button', { name: '下一页', exact: true }); await expect(next).toBeEnabled();
const label = await page.getByText(/^第 \d+ 页$/).first().innerText();
await next.click(); await expect(page.getByText(label, { exact: true })).toHaveCount(0);
}
throw new Error('synthetic employee record not found within ledger pages');
}
try {
const page = await login(finance);
async function selectEmployee(who = s.staff, query = `浏览器-${s.mode}`) {
await page.getByRole('button', { name: '登记出借人', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByRole('heading', { name: '从员工选择出借人' })).toBeVisible();
await dialog.getByLabel('检索员工', { exact: true }).fill(query);
await dialog.getByLabel('已有员工', { exact: true }).selectOption(who.id);
await expect(dialog.getByLabel('借款所属公司', { exact: true })).toHaveValue(s.company.id);
await expect(dialog.getByLabel('员工资料摘要')).toBeVisible();
await expect(dialog.getByRole('textbox', { name: '收款账号', exact: true })).toHaveCount(0);
await expect(dialog.getByRole('textbox', { name: '出借人姓名', exact: true })).toHaveCount(0);
return dialog;
}
let dialog = await selectEmployee();
assert.ok(!(await dialog.innerText()).includes('2'.repeat(16)), 'full employee account is never rendered');
await expect(dialog.getByRole('button', { name: '确认关联员工' })).toBeDisabled();
await page.screenshot({ path: path.join(output, `employee-${s.mode}-source.png`), fullPage: true });
const noOverflow = await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1);
assert.ok(noOverflow, `${s.mode} employee selection has no horizontal overflow`);
// The page's old preview must fail after a real source-row update.
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)) } });
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
const stale = await submit(page, '确认关联员工', 409);
assert.equal((await stale.json()).error.details.reasonCode, 'loan.employee_source_changed');
await expect(dialog.getByRole('alert')).toContainText('员工资料已变化');
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.staff.id } }), 0);
await dialog.getByRole('button', { name: '重新加载员工资料' }).click();
const checked = dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' });
await expect(checked).not.toBeChecked(); await expect(checked).toBeEnabled(); await checked.check();
const linked = await submitAfterSameActor401(page, '确认关联员工', '/api/loan-lenders/from-employee');
const sent = linked.request().postDataJSON();
assert.deepEqual(Object.keys(sent).sort(), ['detailsConfirmed', 'employeeId', 'expectedSourceVersion', 'legalEntityId', 'requestKey'].sort());
let lender = (await linked.json()).data.lender;
assert.equal(lender.employeeId, s.staff.id); assert.equal(lender.identityStatus, 'verified');
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } })).created_by, finance.id);
dialog = await selectEmployee();
await expect(dialog).toContainText('将使用已有记录');
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
assert.equal((await (await submit(page, '使用已有出借人')).json()).data.lender.id, lender.id);
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.staff.id, legal_entity_id: s.company.id } }), 1);
// Missing data points back to personnel maintenance, with no replacement
// account field in either the new-link or refresh dialog.
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: '' } });
dialog = await selectEmployee();
await expect(dialog.getByRole('alert').filter({ hasText: '员工收款账号未维护' })).toBeVisible();
await expect(dialog.getByRole('button', { name: '确认关联员工', exact: true })).toBeDisabled();
await expect(dialog.getByRole('textbox', { name: '收款账号', exact: true })).toHaveCount(0);
await dialog.press('Escape');
await prisma.employee.update({ where: { id: s.staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '4'.repeat(16)) } });
const lenderCard = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${s.company.name}`, exact: true }) });
await findCard(page, lenderCard);
await lenderCard.getByRole('button', { name: '使用当前员工资料', exact: true }).click();
dialog = page.getByRole('dialog');
await expect(dialog).toContainText('原公司代表授权须重新登记确认');
await expect(dialog.getByLabel('员工资料摘要')).toContainText('4444');
await dialog.getByLabel('更新依据', { exact: true }).fill('合成核对员工档案中的新收款资料');
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
lender = (await (await submitAfterSameActor401(page, '确认更新出借人资料', `/api/loan-lenders/${lender.id}/employee-refresh`)).json()).data;
assert.equal(lender.accountVersion, 2); assert.equal(lender.identityVersion, 2);
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } })).updated_by, finance.id);
await findCard(page, lenderCard);
checks.push(`${s.mode}_employee_selection_masked_stale_refusal_reuse_missing_and_explicit_refresh`);
await page.getByRole('button', { name: '登记借入', exact: true }).click();
dialog = page.getByRole('dialog'); await dialog.getByLabel('出借人', { exact: true }).selectOption(lender.id);
const reference = `合成员工历史借入-${s.mode}`;
await dialog.getByLabel('原借入凭据唯一引用').fill(reference);
await dialog.getByLabel('原始本金', { exact: true }).fill('200.00');
await dialog.getByLabel('核对日未还本金').fill('200.00');
await dialog.getByLabel('实际到账日期').fill('2025-05-01');
await dialog.getByLabel('余额核对截止日').fill('2026-01-01');
await submit(page);
const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference, lender_id: lender.id } });
await page.getByRole('tab', { name: '借款台账', exact: true }).click();
const loanCard = page.locator('section.card-std').filter({ has: page.getByRole('heading', { name: `${lender.name} · ${loan.loan_no}`, exact: true }) });
await findCard(page, loanCard); await loanCard.locator('summary').click();
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
const result = 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\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 expect(loanCard).toContainText('余额已确认');
await page.getByRole('tab', { name: '公司代表授权', exact: true }).click();
await page.getByRole('button', { name: '登记公司代表授权范围', exact: true }).click();
dialog = page.getByRole('dialog');
await expect(dialog.getByLabel('授权出借人').locator('option').nth(1)).toBeAttached();
for (let i = 0; !(await dialog.getByLabel('授权出借人').locator(`option[value="${lender.id}"]`).count()); i++) {
assert.ok(i < 20);
const response = page.waitForResponse(r => r.url().includes('/loan-lenders?page='));
await dialog.getByRole('button', { name: '下一页出借人', exact: true }).click(); await response;
}
await dialog.getByLabel('授权出借人').selectOption(lender.id);
await dialog.getByRole('checkbox', { name: new RegExp(loan.loan_no) }).check();
await dialog.getByLabel('代表权及授权范围依据').fill('合成财务登记已有公司代表权及此笔历史借款范围'); await submit(page);
await expect(page.getByRole('button', { name: '代表公司确认授权范围', exact: true })).toHaveCount(0);
const managerPage = await login(s.staff);
await expect(managerPage.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0);
await managerPage.getByRole('button', { name: '代表公司确认授权范围', exact: true }).click();
await expect(managerPage.getByRole('dialog')).toContainText(loan.loan_no);
await managerPage.getByRole('dialog').getByLabel('确认或停止的理由').fill('合成公司代表核对本人收款身份与授权范围'); await submit(managerPage);
const auth = await prisma.loanRelatedAuthorization.findFirstOrThrow({ where: { lender_id: lender.id }, include: { events: true } });
assert.equal(auth.approver_id, s.staff.id); assert.equal(auth.proposed_by, finance.id);
assert.ok(auth.events.some(event => event.action === 'confirm' && event.actor_id === s.staff.id));
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: lender.id } }), 0, 'source and authority setup never makes a repayment');
await managerPage.screenshot({ path: path.join(output, `employee-${s.mode}-authorization.png`), fullPage: true });
checks.push(`${s.mode}_employee_history_balance_finance_authority_and_manager_confirmation`);
// Keep the same document alive across real logout/login, so document
// teardown cannot accidentally provide the request cancellation guard.
await page.evaluate(() => { window.__syntheticEmployeeDocument = 'same-document'; });
async function navigateWithinDocument(to) {
await page.evaluate(url => { window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); }, to);
}
async function switchActor(who) {
await navigateWithinDocument('/mobile/me');
await page.getByRole('button', { name: '退出登录', exact: true }).click();
await expect(page.getByTestId('login-submit')).toBeVisible();
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password);
await page.getByTestId('login-submit').click(); await expect(page.getByTestId('login-submit')).toHaveCount(0);
await navigateWithinDocument(`${prefix}/company-loans`);
await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible();
assert.equal(await page.evaluate(() => window.__syntheticEmployeeDocument), 'same-document');
}
for (const action of ['link', 'refresh']) {
if (action === 'refresh') await switchActor(finance);
else await navigateWithinDocument(`${prefix}/company-loans`);
if (action === 'link') dialog = await selectEmployee(s.switchStaff, `浏览器切换-${s.mode}`);
else {
await page.getByRole('tab', { name: '出借人', exact: true }).click(); await findCard(page, lenderCard);
await lenderCard.getByRole('button', { name: '使用当前员工资料', exact: true }).click(); dialog = page.getByRole('dialog');
await dialog.getByLabel('更新依据', { exact: true }).fill('合成旧会话操作不得转交新登录财务');
}
await dialog.getByRole('checkbox', { name: '已核对借款所属公司、员工身份和收款资料' }).check();
const endpoint = action === 'link' ? '/api/loan-lenders/from-employee' : `/api/loan-lenders/${lender.id}/employee-refresh`;
const before = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
const auditBefore = await prisma.auditLog.count({ where: { target_id: lender.id, action: 'LOAN_LENDER_EMPLOYEE_REFRESHED' } });
let release; const gate = new Promise(resolve => { release = resolve; });
let started; const held = new Promise(resolve => { started = resolve; });
let handled; const finished = new Promise(resolve => { handled = resolve; });
let attempts = 0; let firstRequest; let aborted = false;
const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); };
const handler = async route => {
attempts++;
if (attempts > 1) return route.continue();
firstRequest = route.request(); started(); await gate;
try {
await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late-401',
error: { code: 'AUTH_001', message: '合成旧会话迟到响应' } }) });
} finally { handled(); }
};
page.on('requestfailed', onFailed); await page.route(`**${endpoint}`, handler);
try {
await dialog.getByRole('button', { name: action === 'link' ? '确认关联员工' : '确认更新出借人资料', exact: true }).click();
await held; await switchActor(employeeSources.otherFinance);
release(); await finished;
await expect.poll(() => aborted, { message: 'leaving the source form must abort its old write request' }).toBe(true);
assert.equal(attempts, 1, 'a late 401 must never replay the old write as the new actor');
assert.equal(await prisma.loanLender.count({ where: { employee_id: s.switchStaff.id } }), 0);
const after = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(after.identity_version, before.identity_version); assert.equal(after.account_version, before.account_version);
assert.equal(after.updated_by, before.updated_by);
assert.equal(await prisma.auditLog.count({ where: { target_id: lender.id, action: 'LOAN_LENDER_EMPLOYEE_REFRESHED' } }), auditBefore);
checks.push(`${s.mode}_${action}_late_401_aborted_across_real_actor_switch`);
} finally { release(); page.off('requestfailed', onFailed); await page.unroute(`**${endpoint}`, handler); }
}
} catch (error) {
for (const [index, context] of contexts.entries()) {
const page = context.pages()[0];
if (page) await page.screenshot({ path: path.join(output, `employee-${s.mode}-failure-${index}.png`), fullPage: true, timeout: 5000 }).catch(() => {});
}
throw error;
} finally { for (const context of contexts) await context.close(); }
}
} finally { config.appOrigin = oldOrigin; if (own) await browser.close(); }
console.log(JSON.stringify({ stage: 'loan_employee_browser', status: 'PASS', checks, output: path.relative(root, output) }));
return checks;
};
@@ -0,0 +1,328 @@
const assert = require('node:assert/strict');
const { randomUUID, createHash } = require('node:crypto');
module.exports = async function employeeFixture(h) {
const { prisma, pool, call, app, req, finance, manager, ordinary, config, actor, company, bank,
confirmedLoan, repay, exportOrder, receipt } = h;
const { encryptStringOrThrow, decryptStringOrThrow } = req('./src/services/crypto');
const checks = [];
const sourceUrl = id => `/loan-lenders/employee-source/${id}?legalEntityId=${company.id}`;
const source = who => call(finance, 'GET', sourceUrl(who.id));
async function employee(label, roles = ['employee']) {
const who = await actor(roles);
await prisma.employee.update({ where: { id: who.id }, data: { name: `合成员工-${label}`,
bank_institution_id: bank.id, bank_name: '合成建行员工收款资料',
bank_account_encrypted: encryptStringOrThrow(config, '2'.repeat(16)) } });
return who;
}
const input = (who, preview, key = randomUUID()) => ({ employeeId: who.id, legalEntityId: company.id,
expectedSourceVersion: preview.sourceVersion, detailsConfirmed: true, requestKey: key });
const link = (body, status = 200, reason) => call(finance, 'POST', '/loan-lenders/from-employee', body, status, reason);
const refreshInput = preview => ({ expectedSourceVersion: preview.sourceVersion, detailsConfirmed: true,
expectedIdentityVersion: preview.existingLender.identityVersion, expectedAccountVersion: preview.existingLender.accountVersion,
reason: '合成根据员工档案重新核对姓名及收款资料' });
const refresh = (id, body, status = 200, reason) => call(finance, 'POST', `/loan-lenders/${id}/employee-refresh`, body, status, reason);
const staff = await employee('资料复用', ['manager']);
const employeeCount = await prisma.employee.count();
const preview = await source(staff);
assert.equal(preview.linkState, 'new'); assert.deepEqual(preview.problems, []);
assert.ok(!JSON.stringify(preview).includes('2'.repeat(16)), 'preview must contain only a masked account');
assert.ok(!JSON.stringify(preview).includes('encrypted'), 'preview never exposes encrypted source columns');
const body = input(staff, preview);
const created = await link(body); const lender = created.lender;
assert.equal(created.reused, false); assert.equal(lender.employeeId, staff.id); assert.equal(lender.identityStatus, 'verified');
const persisted = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(persisted.name, preview.employee.name);
assert.ok(decryptStringOrThrow(config, persisted.bank_account_encrypted) === '2'.repeat(16), 'account comes from employee');
assert.ok(decryptStringOrThrow(config, persisted.account_name_encrypted) === preview.employee.name, 'account holder comes from employee name');
assert.equal((await link(body)).lender.id, lender.id);
assert.equal((await link(input(staff, await source(staff)))).reused, true);
assert.equal(await prisma.employee.count(), employeeCount, 'linking never creates an employee');
assert.equal(await prisma.loanLender.count({ where: { employee_id: staff.id, legal_entity_id: company.id } }), 1);
const reuseKey = randomUUID();
await link(input(staff, await source(staff), reuseKey));
const keyTarget = await employee('复用请求键冲突');
await link(input(keyTarget, await source(keyTarget), reuseKey), 409, 'loan.duplicate_registration');
assert.equal(await prisma.loanLender.count({ where: { employee_id: keyTarget.id } }), 0);
assert.equal((await link(input(staff, await source(staff), reuseKey))).lender.id, lender.id);
checks.push('employee_source_masked_server_copy_verified_link_and_reuse');
for (const who of [manager, ordinary, null]) {
await call(who, 'GET', sourceUrl(staff.id), undefined, who ? 403 : 401);
await call(who, 'POST', '/loan-lenders/from-employee', body, who ? 403 : 401);
await call(who, 'POST', `/loan-lenders/${lender.id}/employee-refresh`, refreshInput(await source(staff)), who ? 403 : 401);
}
// An otherwise qualified finance user still needs employee bank permission.
const hasPermission = app.hasPermission;
app.hasPermission = (person, code) => code === 'employee.bank.read' || code === 'employee.bank.manage' ? false : hasPermission(person, code);
try {
await call(finance, 'GET', sourceUrl(staff.id), undefined, 403);
await call(finance, 'POST', '/loan-lenders/from-employee', body, 403);
await call(finance, 'POST', `/loan-lenders/${lender.id}/employee-refresh`, {}, 403);
} finally { app.hasPermission = hasPermission; }
for (const addition of [{ name: '伪造姓名' }, { bankAccount: '9'.repeat(16) }, { accountName: '伪造账户名' }, { detailsConfirmed: false }]) {
await link({ ...body, ...addition }, 400, 'loan.invalid_input');
}
await link({ ...body, legalEntityId: randomUUID() }, 409, 'loan.source_unavailable');
const otherCompany = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: '合成另一个借款公司' } });
const explicitCompanySource = await call(finance, 'GET', `/loan-lenders/employee-source/${staff.id}?legalEntityId=${otherCompany.id}`);
assert.equal(explicitCompanySource.employee.legalEntityId, company.id);
const otherCompanyLender = await link({ ...input(staff, explicitCompanySource), legalEntityId: otherCompany.id });
assert.equal(otherCompanyLender.lender.legalEntityId, otherCompany.id, 'employee employer must not replace the explicitly chosen debtor');
checks.push('employee_source_role_bank_permission_and_strict_input_guards');
const incomplete = await employee('资料缺项');
const normal = await prisma.employee.findUniqueOrThrow({ where: { id: incomplete.id } });
const unsupported = await prisma.bankInstitution.create({ data: { code: `SYN-${randomUUID().slice(0, 8)}`, name: '合成其他银行' } });
for (const [patch, problem] of [
[{ name: '' }, 'name_invalid'], [{ bank_name: '' }, 'bank_missing'],
[{ bank_institution_id: null }, 'bank_missing'], [{ bank_institution_id: unsupported.id }, 'bank_unsupported'],
[{ bank_account_encrypted: '' }, 'account_missing'], [{ bank_account_encrypted: 'synthetic-unreadable' }, 'account_unavailable'],
[{ bank_account_encrypted: encryptStringOrThrow({ fieldEncryptionKey: Buffer.alloc(32, 7).toString('base64') }, '3'.repeat(16)) }, 'account_unavailable'],
[{ bank_account_encrypted: encryptStringOrThrow(config, 'invalid-account') }, 'account_invalid'],
]) {
await prisma.employee.update({ where: { id: incomplete.id }, data: { name: normal.name, bank_name: normal.bank_name,
bank_institution_id: normal.bank_institution_id, bank_account_encrypted: normal.bank_account_encrypted, ...patch } });
const invalid = await source(incomplete); assert.ok(invalid.problems.includes(problem), `expected ${problem}; observed ${invalid.problems.join(',')}`);
await link(input(incomplete, invalid), 409, 'loan.employee_source_incomplete');
}
await prisma.employee.update({ where: { id: incomplete.id }, data: { status: 'archived' } });
await call(finance, 'GET', sourceUrl(incomplete.id), undefined, 409, 'loan.source_unavailable');
assert.equal(await prisma.loanLender.count({ where: { employee_id: incomplete.id } }), 0);
checks.push('employee_missing_unreadable_unsupported_and_inactive_sources_rejected');
const stale = await employee('预览变更');
const old = await source(stale);
const row = await prisma.employee.findUniqueOrThrow({ where: { id: stale.id } });
// Holding updated_at constant proves the version binds actual fields too.
await prisma.employee.update({ where: { id: stale.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)), updated_at: row.updated_at } });
await link(input(stale, old), 409, 'loan.employee_source_changed');
assert.equal(await prisma.loanLender.count({ where: { employee_id: stale.id } }), 0);
const fresh = await source(stale);
const freshLender = (await link(input(stale, fresh))).lender;
await link({ ...input(stale, fresh), requestKey: body.requestKey }, 409, 'loan.duplicate_registration');
checks.push('employee_source_version_covers_fields_and_request_key_conflicts');
async function manualFor(who) {
const e = await prisma.employee.findUniqueOrThrow({ where: { id: who.id } });
return call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name: e.name,
bankInstitutionId: bank.id, bankName: e.bank_name, bankAccount: decryptStringOrThrow(config, e.bank_account_encrypted), accountName: e.name });
}
const other = await manualFor(staff);
await call(finance, 'POST', `/loan-lenders/${other.id}/identity-confirmation`, { expectedVersion: other.identityVersion,
employeeId: staff.id, externalPersonConfirmed: false, reason: '合成旧入口重复关联拒绝' }, 409, 'loan.employee_lender_conflict');
assert.equal((await prisma.loanLender.findUniqueOrThrow({ where: { id: other.id } })).employee_id, null);
// Model an old ambiguous dataset directly; this is not an application path.
await prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: staff.id } });
assert.equal((await source(staff)).linkState, 'conflict');
await link(input(staff, await source(staff)), 409, 'loan.employee_lender_conflict');
await refresh(lender.id, refreshInput({ ...preview, existingLender: lender }), 409, 'loan.employee_lender_conflict');
await prisma.loanLender.update({ where: { id: other.id }, data: { employee_id: null } });
await prisma.loanLender.update({ where: { id: freshLender.id }, data: { archived_at: new Date() } });
assert.equal((await source(stale)).linkState, 'conflict');
await link(input(stale, await source(stale)), 409, 'loan.employee_lender_conflict');
checks.push('legacy_identity_entry_and_ambiguous_or_archived_links_cannot_duplicate');
const { Pool } = req('pg');
// Pause the real refresh immediately before its audit INSERT checks actor A's
// FK. It already holds employee B; B's concurrent link locks A then waits on B.
// An UPDATE lock on A would deadlock with the audit's implicit KEY SHARE.
const [financeA, financeB] = (await Promise.all([
employee('外键交错甲', ['finance']), employee('外键交错乙', ['finance']),
])).sort((a, b) => a.id.localeCompare(b.id));
const fkLender = (await call(financeA, 'POST', '/loan-lenders/from-employee', input(financeB, await source(financeB)))).lender;
const fkRefresh = refreshInput(await source(financeB));
const fkLink = input(financeA, await source(financeA));
const fkLocks = new Pool({ ...pool.options, max: 2 });
const fkHolder = await fkLocks.connect(); const fkResponses = [];
const deadlocksBefore = Number((await pool.query('SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()')).rows[0].deadlocks);
await pool.query(`CREATE FUNCTION synthetic_pause_employee_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.target_id = TG_ARGV[0]::uuid THEN PERFORM pg_advisory_xact_lock(293174183); END IF; RETURN NEW; END $$`);
await pool.query(`CREATE TRIGGER synthetic_employee_fk_pause BEFORE INSERT ON audit_logs FOR EACH ROW
WHEN (NEW.action = 'LOAN_LENDER_EMPLOYEE_REFRESHED') EXECUTE FUNCTION synthetic_pause_employee_audit('${fkLender.id}')`);
let fkResults; let fkLinkResponse;
try {
await fkHolder.query('SELECT pg_advisory_lock(293174183)');
fkResponses.push(app.inject({ method: 'POST', url: `/api/loan-lenders/${fkLender.id}/employee-refresh`,
headers: financeA.headers, payload: fkRefresh }).then(r => r));
async function observeFkWait(count) {
for (let i = 0; i < 200; i++) {
const rows = await fkLocks.query(`SELECT count(*)::int AS n, count(*) FILTER (WHERE wait_event='advisory')::int AS advisory
FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'`);
if (rows.rows[0].n >= count && rows.rows[0].advisory === 1) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
if (count === 2 && fkLinkResponse?.statusCode === 200) {
throw Object.assign(new Error('source association finished without waiting for the audit-held employee'), { code: 'EMPLOYEE_SOURCE_LOCK_NOT_HELD' });
}
throw new Error('synthetic audit/employee interleaving did not reach both database lock waits');
}
await observeFkWait(1);
fkResponses.push(app.inject({ method: 'POST', url: '/api/loan-lenders/from-employee',
headers: financeB.headers, payload: fkLink }).then(r => { fkLinkResponse = r; return r; }));
await observeFkWait(2);
await fkHolder.query('SELECT pg_advisory_unlock(293174183)');
fkResults = await Promise.all(fkResponses);
} finally {
await fkHolder.query('SELECT pg_advisory_unlock(293174183)'); fkHolder.release();
await Promise.allSettled(fkResponses); await fkLocks.end();
await pool.query('DROP TRIGGER synthetic_employee_fk_pause ON audit_logs');
await pool.query('DROP FUNCTION synthetic_pause_employee_audit()');
}
if (fkResults.some(r => r.statusCode !== 200)) {
// Classify the reverse mutation only with PostgreSQL's actual deadlock
// counter, never a generic 500, timeout or fixture/environment failure.
for (let i = 0; i < 200; i++) {
const count = Number((await pool.query('SELECT deadlocks FROM pg_stat_database WHERE datname=current_database()')).rows[0].deadlocks);
if (count > deadlocksBefore) throw Object.assign(new Error('observed employee source/audit FK deadlock'), { code: 'EMPLOYEE_SOURCE_FK_DEADLOCK' });
await new Promise(resolve => setTimeout(resolve, 10));
}
}
assert.deepEqual(fkResults.map(r => r.statusCode), [200, 200], 'refresh audit and concurrent source association must both finish');
assert.equal(fkResults[0].json().data.identityVersion, fkLender.identityVersion + 1);
assert.equal(fkResults[1].json().data.lender.employeeId, financeA.id);
assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_EMPLOYEE_REFRESHED', target_id: fkLender.id } }), 1);
checks.push('employee_source_locks_allow_implicit_audit_fk_without_deadlock');
const locks = new Pool({ ...pool.options, max: 2 });
async function waiters(count) {
for (let i = 0; i < 200; i++) {
const result = await locks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'");
if (result.rows[0].n >= count) return;
await new Promise(resolve => setTimeout(resolve, 10));
}
throw new Error('synthetic employee operation did not actually wait for the row lock');
}
async function interleave(who, starts) {
const holder = await locks.connect(); const responses = [];
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM employees WHERE id=$1 FOR UPDATE', [who.id]);
for (const start of starts) { responses.push(start()); await waiters(responses.length); }
await holder.query('COMMIT'); return await Promise.all(responses);
} finally { await holder.query('ROLLBACK'); holder.release(); await Promise.allSettled(responses); }
}
const startLink = body => () => app.inject({ method: 'POST', url: '/api/loan-lenders/from-employee', headers: finance.headers, payload: body }).then(r => r);
try {
const simultaneous = await employee('并发新建'); const p = await source(simultaneous);
const replies = await interleave(simultaneous, [startLink(input(simultaneous, p)), startLink(input(simultaneous, p))]);
assert.deepEqual(replies.map(r => r.statusCode), [200, 200]);
assert.equal(replies[0].json().data.lender.id, replies[1].json().data.lender.id);
assert.equal(await prisma.loanLender.count({ where: { employee_id: simultaneous.id, legal_entity_id: company.id } }), 1);
const keyed = await employee('并发复用请求键'); const concurrentKey = randomUUID();
const keyedReplies = await interleave(finance, [startLink(input(staff, await source(staff), concurrentKey)), startLink(input(keyed, await source(keyed), concurrentKey))]);
assert.deepEqual(keyedReplies.map(r => r.statusCode), [200, 409]);
assert.equal(keyedReplies[1].json().error?.details.reasonCode, 'loan.duplicate_registration');
assert.equal(await prisma.loanLender.count({ where: { employee_id: keyed.id } }), 0);
for (const legacyFirst of [false, true]) {
const who = await employee(`并发旧入口-${legacyFirst}`); const p = await source(who); const manual = await manualFor(who);
const legacy = () => app.inject({ method: 'POST', url: `/api/loan-lenders/${manual.id}/identity-confirmation`, headers: finance.headers,
payload: { expectedVersion: manual.identityVersion, employeeId: who.id, externalPersonConfirmed: false, reason: '合成并发身份关联' } }).then(r => r);
const replies = await interleave(who, legacyFirst ? [legacy, startLink(input(who, p))] : [startLink(input(who, p)), legacy]);
assert.deepEqual(replies.map(r => r.statusCode), legacyFirst ? [200, 200] : [200, 409]);
if (!legacyFirst) assert.equal(replies[1].json().error?.details.reasonCode, 'loan.employee_lender_conflict');
assert.equal(await prisma.loanLender.count({ where: { employee_id: who.id, legal_entity_id: company.id } }), 1);
}
} finally { await locks.end(); }
checks.push('employee_creation_and_legacy_entry_observed_lock_interleavings');
const loan = await confirmedLoan(lender.id, '100.00', `合成员工原借入-${randomUUID()}`);
async function authorize() {
const auth = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: lender.id, loanIds: [loan.id],
requestKey: randomUUID(), basis: '合成已存在的公司代表权及此笔借款范围' });
await call(staff, 'POST', `/loan-related-authorizations/${auth.id}/actions`, { action: 'confirm', reason: '合成公司代表本人确认授权范围' });
return auth;
}
const auth = await authorize(); const batch = await repay(lender.id, loan.id, '10.00');
assert.equal(batch.loanRepayment.approvalMode, 'related_party');
await call(staff, 'POST', `/payment-batches/${batch.id}/review`, {});
await exportOrder(batch.id);
async function snapshots() {
const repayment = await prisma.loanRepayment.findUniqueOrThrow({ where: { id: batch.loanRepaymentId },
select: { lender_identity_version: true, lender_account_version: true, payee_snapshot_json: true, approval_context: true } });
const savedBatch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id },
select: { payment_account_snapshot_json: true, items: { select: { snapshot_json: true } } } });
return createHash('sha256').update(JSON.stringify({ repayment, savedBatch })).digest('hex');
}
const originalSnapshots = await snapshots();
const beforeChange = await source(staff);
await prisma.employee.update({ where: { id: staff.id }, data: { bank_account_encrypted: encryptStringOrThrow(config, '3'.repeat(16)) } });
assert.equal(await snapshots(), originalSnapshots);
const changed = await source(staff); assert.equal(changed.linkState, 'refresh_required'); assert.equal(changed.existingLender.hasInFlightRepayment, true);
await link(input(staff, changed), 409, 'loan.employee_lender_refresh_required');
await refresh(lender.id, refreshInput(changed), 409, 'loan.lender_in_flight');
const proof = await receipt(batch.id, '1000', `SYN-EMPLOYEE-${randomUUID()}`, { payeeAccount: '2'.repeat(16) });
assert.equal(proof.result.status, 'matched');
assert.equal((await call(finance, 'POST', `/payment-batches/${batch.id}/execute`, {})).item.status, 'executed');
await refresh(lender.id, refreshInput(beforeChange), 409, 'loan.employee_source_changed');
const updated = await refresh(lender.id, refreshInput(await source(staff)));
assert.equal(updated.accountVersion, lender.accountVersion + 1); assert.equal(updated.identityVersion, lender.identityVersion + 1);
assert.equal(updated.identityStatus, 'verified'); assert.equal(updated.employeeId, staff.id);
assert.equal(await snapshots(), originalSnapshots, 'explicit source refresh never rewrites historical payment snapshots');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '90.00');
await refresh(lender.id, refreshInput(changed), 409, 'loan.version_changed');
await call(finance, 'PATCH', `/loan-lenders/${lender.id}/account`, { expectedVersion: updated.accountVersion,
bankInstitutionId: bank.id, bankName: '伪造覆盖', bankAccount: '9'.repeat(16), accountName: '伪造覆盖' }, 409, 'loan.employee_account_managed');
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: lender.id, reason: '合成旧授权拒绝',
allocations: [{ loanId: loan.id, amount: '1.00' }] }, 409, 'loan.related_scope_invalid');
assert.equal((await prisma.loanRelatedAuthorization.findUniqueOrThrow({ where: { id: auth.id } })).identity_version, lender.identityVersion);
await authorize(); const renewed = await repay(lender.id, loan.id, '1.00');
await call(finance, 'POST', `/payment-batches/${renewed.id}/cancel`, { reason: '合成刷新后新授权验证结束' });
checks.push('employee_refresh_inflight_guard_old_payment_snapshot_settlement_and_authority_versions');
const raceLocks = new Pool({ ...pool.options, max: 2 });
try {
for (const first of ['create', 'refresh']) {
await authorize();
const current = await source(staff); const holder = await raceLocks.connect(); const responses = [];
let results;
try {
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [lender.id]);
const starts = {
create: () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: {
requestKey: randomUUID(), lenderId: lender.id, reason: '合成员工刷新并发还款', allocations: [{ loanId: loan.id, amount: '1.00' }] } }).then(r => r),
refresh: () => app.inject({ method: 'POST', url: `/api/loan-lenders/${lender.id}/employee-refresh`, headers: finance.headers,
payload: refreshInput(current) }).then(r => r),
};
for (const action of [first, first === 'create' ? 'refresh' : 'create']) {
responses.push(starts[action]());
let waiting = false;
for (let i = 0; i < 200; i++) {
const count = await raceLocks.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'");
if (count.rows[0].n >= responses.length) { waiting = true; break; }
await new Promise(resolve => setTimeout(resolve, 10));
}
assert.ok(waiting, 'both source refresh and repayment creation must actually wait on the lender lock');
}
await holder.query('COMMIT'); results = await Promise.all(responses);
} finally { await holder.query('ROLLBACK'); holder.release(); await Promise.allSettled(responses); }
assert.deepEqual(results.map(r => r.statusCode), [200, 409], `${first} wins and the other action refuses stale or reserved data`);
assert.equal(results[1].json().error?.details.reasonCode, first === 'create' ? 'loan.lender_in_flight' : 'loan.related_scope_invalid');
const currentLender = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(await prisma.loanRepayment.count({ where: { lender_id: lender.id, allocations: { some: { reservation_state: 'reserved' } },
OR: [{ lender_identity_version: { not: currentLender.identity_version } }, { lender_account_version: { not: currentLender.account_version } }] } }), 0);
if (first === 'create') await call(finance, 'POST', `/payment-batches/${results[0].json().data.item.id}/cancel`, { reason: '合成员工刷新交错验证结束' });
}
} finally { await raceLocks.end(); }
checks.push('employee_refresh_and_repayment_creation_both_observed_lender_lock_orders');
const rollbackStaff = await employee('审计回滚'); const rollbackSource = await source(rollbackStaff);
await pool.query(`CREATE FUNCTION synthetic_fail_employee_lender_audit() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN IF NEW.action IN ('LOAN_LENDER_EMPLOYEE_CREATED','LOAN_LENDER_EMPLOYEE_REFRESHED') THEN RAISE EXCEPTION 'synthetic audit unavailable'; END IF; RETURN NEW; END $$`);
await pool.query('CREATE TRIGGER synthetic_employee_lender_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_employee_lender_audit()');
try {
await link(input(rollbackStaff, rollbackSource), 500);
assert.equal(await prisma.loanLender.count({ where: { employee_id: rollbackStaff.id } }), 0);
const state = await source(staff); await refresh(lender.id, refreshInput(state), 500);
const retained = await prisma.loanLender.findUniqueOrThrow({ where: { id: lender.id } });
assert.equal(retained.identity_version, state.existingLender.identityVersion); assert.equal(retained.account_version, state.existingLender.accountVersion);
} finally {
await pool.query('DROP TRIGGER synthetic_employee_lender_audit ON audit_logs');
await pool.query('DROP FUNCTION synthetic_fail_employee_lender_audit()');
}
checks.push('employee_source_creation_and_refresh_audit_failure_roll_back');
const ui = [];
for (const mode of ['desktop', 'mobile']) ui.push({ mode, staff: await employee(`浏览器-${mode}`, ['manager']),
switchStaff: await employee(`浏览器切换-${mode}`), company });
console.log(JSON.stringify({ stage: 'loan_employee_http', status: 'PASS', checks }));
return { ui, checks, otherFinance: financeB };
};
@@ -0,0 +1,49 @@
const assert = require('node:assert/strict');
const { readFileSync } = require('node:fs');
const path = require('node:path');
const Module = require('node:module');
// Mutate only the isolated fixture process. Repository files and the production
// module cache remain unchanged; all route exports are restored after each case.
module.exports = async function employeeMutations(h, runFixture) {
const filename = h.req.resolve('./src/services/loanLenderEmployee');
const source = readFileSync(filename, 'utf8');
const live = h.req('./src/services/loanLenderEmployee'); const original = { ...live };
const mutations = [
['bank_permission', 'if (!fastify.hasPermission(request.employee, "employee.bank.read") &&\n !fastify.hasPermission(request.employee, "employee.bank.manage"))', 'if (false)'],
['source_version', 'if (source.sourceVersion !== expectedSourceVersion)', 'if (false)'],
['legacy_duplicate', 'if (links.some((lender) => lender.id !== lenderId))', 'if (false)'],
['employee_lock', 'if (unique.length) await tx.$queryRaw', 'if (false) await tx.$queryRaw'],
['employee_lock_fk_compatibility', 'ORDER BY id FOR NO KEY UPDATE', 'ORDER BY id FOR UPDATE'],
['in_flight', 'await assertLenderNotInFlight(tx, id);', ''],
['lender_versions', 'assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);\n assertCurrentVersion(lender.account_version, input.expectedAccountVersion);', ''],
['reuse_request_binding', 'if (!previousRequest) await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REUSED", lender.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
['required_audit', 'await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {\n ...requestBinding, employeeSourceRequestKey: input.requestKey,\n });', ''],
];
const results = [];
for (const [name, from, to] of mutations) {
assert.equal(source.split(from).length, 2, `mutation ${name} must have exactly one target`);
const mutant = new Module(filename, module); mutant.filename = filename;
mutant.paths = Module._nodeModulePaths(path.dirname(filename));
mutant._compile(source.replace(from, to), filename);
let failure;
Object.assign(live, mutant.exports);
try { await runFixture(h); } catch (error) { failure = error; }
finally { Object.assign(live, original); }
assert.ok(failure, `mutation ${name} survived`);
// A generic environment, SQL or parsing failure is not a killed guard.
const wrongAcceptance = failure.code === 'ERR_ASSERTION' && failure.actual === 200 && [403, 409, 500].includes(failure.expected);
const duplicateIdentity = name === 'employee_lock' && failure.code === 'ERR_ASSERTION' &&
typeof failure.actual === 'string' && typeof failure.expected === 'string' &&
/^[0-9a-f-]{36}$/.test(failure.actual) && /^[0-9a-f-]{36}$/.test(failure.expected) && failure.actual !== failure.expected;
const missingLock = name === 'employee_lock' && (failure.message === 'synthetic employee operation did not actually wait for the row lock' ||
failure.code === 'EMPLOYEE_SOURCE_LOCK_NOT_HELD');
const fkDeadlock = name === 'employee_lock_fk_compatibility' && failure.code === 'EMPLOYEE_SOURCE_FK_DEADLOCK';
const hint = failure.code === 'ERR_ASSERTION' ? String(failure.message).split('\n')[0] : failure.name;
assert.ok(wrongAcceptance || duplicateIdentity || missingLock || fkDeadlock, `mutation ${name} failed outside its guard: ${hint} ${failure.code ?? ''}`);
results.push({ name, caught: true, witness: wrongAcceptance ? 'unsafe_http_acceptance' : duplicateIdentity ? 'duplicate_employee_lender' : fkDeadlock ? 'observed_postgres_deadlock' : 'missing_row_lock' });
console.log(JSON.stringify({ stage: 'loan_employee_mutation', ...results.at(-1) }));
}
console.log(JSON.stringify({ stage: 'loan_employee_mutations', status: 'PASS', count: results.length, sourceFilesChanged: false }));
return results;
};
+46 -13
查看文件
@@ -19,9 +19,18 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
paymentBatchEnabled: true,
};
app.decorate('config', config); app.decorate('prisma', prisma);
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({
code: error.code ?? 'SYS_001', details: error.details ?? {},
}));
const { AppError } = req('./src/utils/AppError');
app.setErrorHandler((error, request, reply) => {
const known = error instanceof AppError;
const tooLarge = error.code === 'FST_REQ_FILE_TOO_LARGE';
// Match the production envelope, so browser refusals exercise real reason
// projection instead of silently falling back to a generic server failure.
return reply.code(tooLarge ? 400 : known ? error.statusCode : 500).send({ requestId: request.id, error: {
code: tooLarge ? 'VALID_001' : known ? error.code : 'SYS_001',
message: tooLarge ? '参数校验失败' : known ? error.message : '服务器内部错误',
details: tooLarge ? { file: 'file_too_large' } : known ? error.details ?? {} : {},
} });
});
await app.register(req('@fastify/cookie'));
await app.register(req('@fastify/multipart'));
await app.register(req('./src/plugins/auth'));
@@ -64,8 +73,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
const ordinary = await actor(['employee']);
async function call(who, method, url, payload, status = 200, reason) {
const response = await app.inject({ method, url: `/api${url}`, headers: who?.headers ?? {}, payload });
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().details?.reasonCode ?? ''}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
assert.equal(response.statusCode, status, `${method} ${url} => ${response.statusCode} ${response.json().error?.details?.reasonCode ?? ''}`);
if (reason) assert.equal(response.json().error?.details?.reasonCode, reason);
return response.json().data;
}
for (const who of [manager, system, admin, ordinary]) {
@@ -216,7 +225,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
return result;
}
const exportResponse = await app.inject({ method: 'POST', url: `/api/payment-batches/${batch.id}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().details)}`);
assert.equal(exportResponse.statusCode, 200, `loan bank-order export ${exportResponse.statusCode === 200 ? '' : JSON.stringify(exportResponse.json().error?.details)}`);
assert.equal(exportResponse.rawPayload.subarray(0, 8).toString('hex'), 'd0cf11e0a1b11ae1');
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).outstanding, '800.05');
await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '制单后银行结果待查' }, 409, 'loan.bank_state_unsafe');
@@ -367,7 +376,16 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
// Fresh lenders below: the first lender keeps unknown-state reservations from
// the negative receipt loop above, which by design block its maintenance.
async function verifiedLender(name, employeeId, digit, account = digit.repeat(16)) {
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId: company.id, name,
// Each independent scenario needs its own company when the same synthetic
// employee already has a lender there. Do not seed a forbidden duplicate.
let legalEntityId = company.id;
if (employeeId && await prisma.loanLender.count({ where: { employee_id: employeeId, legal_entity_id: company.id } })) {
const isolatedCompany = await prisma.legalEntity.create({ data: { code: `SYN-${randomUUID()}`, name: `合成场景公司-${randomUUID().slice(0, 8)}` } });
legalEntityId = isolatedCompany.id;
await prisma.paymentAccount.create({ data: { legal_entity_id: legalEntityId, bank_institution_id: bank.id,
account_name: '合成付款公司', account_number_encrypted: encryptStringOrThrow(config, payerAccount) } });
}
const created = await call(finance, 'POST', '/loan-lenders', { requestKey: randomUUID(), legalEntityId, name,
bankInstitutionId: bank.id, bankName: '合成银行', bankAccount: account, accountName: name });
const confirmedLender = await call(finance, 'POST', `/loan-lenders/${created.id}/identity-confirmation`, {
expectedVersion: created.identityVersion, employeeId, externalPersonConfirmed: !employeeId, reason: '合成核实身份' });
@@ -384,8 +402,8 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
requestKey: randomUUID(), lenderId, reason: '合成还本', allocations: [{ loanId, amount }] })).item;
async function exportOrder(batchId, status = 200, reason) {
const response = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/payment-order`, headers: finance.headers, payload: {} });
assert.equal(response.statusCode, status, `export ${response.statusCode} ${response.headers['content-type']?.includes('json') ? response.json().details?.reasonCode ?? '' : '<binary omitted>'}`);
if (reason) assert.equal(response.json().details?.reasonCode, reason);
assert.equal(response.statusCode, status, `export ${response.statusCode} ${response.headers['content-type']?.includes('json') ? response.json().error?.details?.reasonCode ?? '' : '<binary omitted>'}`);
if (reason) assert.equal(response.json().error?.details?.reasonCode, reason);
}
const lenderState = async (id) => (await call(finance, 'GET', '/loan-lenders')).items.find((item) => item.id === id);
const independent = await actor(['ceo']);
@@ -396,6 +414,15 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-related-browser.cjs')({ app, finance, manager, ordinary, password, prisma, config, relatedParty });
return;
}
const employeeHarness = { prisma, pool, call, app, req, finance, manager, ordinary,
config, password, actor, company, bank, confirmedLoan, repay, exportOrder, receipt };
const employeeSources = await require('./company-loan-employee-fixture.cjs')(employeeHarness);
if (process.env.RUN_ISOLATED_LOAN_EMPLOYEE_MUTATIONS === '1') await require('./company-loan-employee-mutations.cjs')(
employeeHarness, require('./company-loan-employee-fixture.cjs'));
if (process.env.RUN_ISOLATED_LOAN_EMPLOYEE_ONLY === '1') {
if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-employee-browser.cjs')({ app, finance, password, prisma, config, employeeSources });
return;
}
// Lender linked to a manager: that manager cannot approve, an independent CEO can.
@@ -433,6 +460,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
checks.push('reserved_allocation_blocks_lender_maintenance_before_and_after_export');
// Account change after release revokes identity and blocks new requests until re-verified.
await accountChange((await lenderState(linked.id)).accountVersion, 409, 'loan.employee_account_managed');
// The hand-entered account path now belongs only to a verified external
// person. Employee refresh is covered separately with real source data.
await call(finance, 'POST', `/loan-lenders/${linked.id}/identity-confirmation`, {
expectedVersion: (await lenderState(linked.id)).identityVersion, employeeId: null,
externalPersonConfirmed: true, reason: '合成旧手工账户路径隔离为非员工测试' });
const changedAccount = await accountChange((await lenderState(linked.id)).accountVersion);
assert.equal(changedAccount.identityStatus, 'unverified');
await call(finance, 'POST', '/loan-repayments', { requestKey: randomUUID(), lenderId: linked.id, reason: '合成未核实',
@@ -471,7 +504,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
if (first === 'create') {
assert.equal(created.statusCode, 200);
assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().details?.reasonCode, 'loan.lender_in_flight');
assert.equal(maintained.statusCode, 409); assert.equal(maintained.json().error?.details?.reasonCode, 'loan.lender_in_flight');
} else {
assert.equal(maintained.statusCode, 200, 'maintenance first succeeds');
assert.equal(created.statusCode, 200, 'creation after maintenance uses the new versions');
@@ -699,7 +732,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
assert.equal(uploaded.statusCode, 200, `${order} competitor upload`);
if (order === 'settle_first') assert.equal(executed.statusCode, 200, 'settlement checked before the competitor existed');
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().details?.reasonCode, 'loan.receipt_attribution_required'); }
else { assert.equal(executed.statusCode, 409); assert.equal(executed.json().error?.details?.reasonCode, 'loan.receipt_attribution_required'); }
// Keep the shared OCR queue clean for later scenarios.
await call(finance, 'POST', `/payment-batches/${competitor.id}/receipt-attachments/${uploaded.json().data.items[0].id}/void`, { reason: '合成并发验证结束' });
}
@@ -1181,7 +1214,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
const dupEvidence = await app.inject({ method: 'POST', url: `/api/payment-batches/${wdB.id}/loan-terminal-evidences`,
headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${dupBoundary}` },
payload: Buffer.concat([Buffer.from(`--${dupBoundary}\r\nContent-Disposition: form-data; name="file"; filename="bank-letter.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), sharedLetter, Buffer.from(`\r\n--${dupBoundary}--\r\n`)]) });
assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().details?.reasonCode, 'loan.terminal_evidence_invalid');
assert.equal(dupEvidence.statusCode, 409); assert.equal(dupEvidence.json().error?.details?.reasonCode, 'loan.terminal_evidence_invalid');
checks.push('s3_withdrawn_ref_and_bank_letter_cannot_move_to_another_batch');
// Evidence separation and history.
@@ -1356,7 +1389,7 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
reconciliationUi.late.push({ id: original.id, lenderId: lateLender.id, reference: `UI-LATE-${mode}`,
payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted), payee: lateLender.bankAccount });
}
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty });
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty, employeeSources });
}
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
} finally { await app.close(); await prisma.$disconnect(); }
+1 -1
查看文件
@@ -37,7 +37,7 @@ module.exports = async function relatedBrowser(h) {
await d.getByRole('button', { name: confirm, exact: true }).click();
const result = await response;
if (!result.ok()) {
const failure = (await result.json().catch(() => ({}))).details?.reasonCode;
const failure = (await result.json().catch(() => ({}))).error?.details?.reasonCode;
throw new Error(`related ${s.mode} ${confirm}: HTTP ${result.status()} ${failure ?? ''}`);
}
try { await expect(d).not.toBeVisible({ timeout: 15000 }); }
+1 -1
查看文件
@@ -540,7 +540,7 @@ module.exports = async function relatedFixture(h) {
const results = await Promise.all([decision, stopped]);
assert.equal(results[1].statusCode, 200, `stop during ${kind}`);
assert.equal(results[0].statusCode, stopFirst ? 409 : 200, `${kind} stopFirst=${stopFirst}: ${results[0].statusCode}`);
if (stopFirst) assert.equal(results[0].json().details.reasonCode, 'loan.related_authorization_required');
if (stopFirst) assert.equal(results[0].json().error?.details.reasonCode, 'loan.related_authorization_required');
} finally { await holder.query('ROLLBACK').catch(() => {}); holder.release(); }
const state = await balance(s);
assert.equal(state.outstanding, '100.00'); assert.equal(state.reserved, kind === 'release' && !stopFirst ? '0.00' : '10.00');
+5
查看文件
@@ -1,4 +1,9 @@
const ERROR_REASONS = [
{"code":"loan.employee_source_incomplete","domain":"loan","audience":"user","defaultMessage":"员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。","recovery":"follow_guidance"},
{"code":"loan.employee_source_changed","domain":"loan","audience":"user","defaultMessage":"员工资料已变化,请重新加载并核对后再保存。","recovery":"follow_guidance"},
{"code":"loan.employee_lender_conflict","domain":"loan","audience":"user","defaultMessage":"该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。","recovery":"follow_guidance"},
{"code":"loan.employee_lender_refresh_required","domain":"loan","audience":"user","defaultMessage":"已有出借人资料与当前员工资料不同,请核对后使用“使用当前员工资料”更新。","recovery":"follow_guidance"},
{"code":"loan.employee_account_managed","domain":"loan","audience":"user","defaultMessage":"员工出借人的收款资料来自员工档案,请先维护员工资料,再使用“使用当前员工资料”更新。","recovery":"follow_guidance"},
{"code":"loan.related_authorization_required","domain":"loan","audience":"user","defaultMessage":"本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。","recovery":"follow_guidance"},
{"code":"loan.related_scope_invalid","domain":"loan","audience":"user","defaultMessage":"关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。","recovery":"follow_guidance"},
{"code":"loan.related_actor_conflict","domain":"loan","audience":"user","defaultMessage":"关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。","recovery":"follow_guidance"},
+29 -2
查看文件
@@ -14,12 +14,14 @@ const {
const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = require("../services/storage");
const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy");
const approvalPolicy = require("../services/loanApprovalPolicy");
const employeeLenders = require("../services/loanLenderEmployee");
const UUID = z.string().uuid();
const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString);
const MONEY = z.string().regex(/^\d{1,12}(\.\d{1,2})?$/);
const TEXT = z.string().trim().min(2).max(500);
const VERSION = z.number().int().min(1).max(2147483646);
const EMPLOYEE_SOURCE = z.object({ expectedSourceVersion: z.string().regex(/^[a-f0-9]{64}$/), detailsConfirmed: z.literal(true) });
const ACCOUNT = z.object({
bankInstitutionId: UUID, bankName: z.string().trim().min(1).max(200),
bankAccount: z.string().regex(/^\d{10,30}$/), accountName: z.string().trim().min(1).max(120),
@@ -131,8 +133,30 @@ async function companyLoansRoutes(fastify) {
const query = parse(z.object({ q: z.string().trim().max(100).default("") }).strict(), request.query ?? {});
const items = await fastify.prisma.employee.findMany({ where: { status: "active", archived_at: null,
...(query.q ? { OR: [{ name: { contains: query.q, mode: "insensitive" } }, { employee_no: { contains: query.q } }] } : {}),
}, select: { id: true, name: true, employee_no: true }, orderBy: [{ employee_no: "asc" }, { id: "asc" }], take: 100 });
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, name: item.name, employeeNo: item.employee_no })) });
}, select: { id: true, name: true, employee_no: true, legal_entity_id: true }, orderBy: [{ employee_no: "asc" }, { id: "asc" }], take: 100 });
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, name: item.name, employeeNo: item.employee_no, legalEntityId: item.legal_entity_id })) });
});
fastify.get("/loan-lenders/employee-source/:employeeId", auth, async (request, reply) => {
employeeLenders.ensureEmployeeBankAccess(fastify, request);
const employeeId = parse(UUID, request.params.employeeId);
const query = parse(z.object({ legalEntityId: UUID }).strict(), request.query ?? {});
const preview = await employeeLenders.previewEmployeeLender(fastify.prisma, fastify.config, employeeId, query.legalEntityId);
reply.header("Cache-Control", "no-store");
return sendData(reply, request, preview);
});
fastify.post("/loan-lenders/from-employee", auth, async (request, reply) => {
employeeLenders.ensureEmployeeBankAccess(fastify, request);
const body = parse(EMPLOYEE_SOURCE.extend({ requestKey: UUID, employeeId: UUID, legalEntityId: UUID }).strict(), request.body);
const result = await employeeLenders.linkEmployeeLender(fastify.prisma, fastify.config, request, body);
const inFlight = await inFlightLenderIds(fastify.prisma, [result.lender.id]);
return sendData(reply, request, { lender: mapLender(fastify.config, result.lender, { inFlight: inFlight.has(result.lender.id) }), reused: result.reused });
});
fastify.post("/loan-lenders/:id/employee-refresh", auth, async (request, reply) => {
employeeLenders.ensureEmployeeBankAccess(fastify, request);
const id = parse(UUID, request.params.id);
const body = parse(EMPLOYEE_SOURCE.extend({ expectedIdentityVersion: VERSION, expectedAccountVersion: VERSION, reason: TEXT }).strict(), request.body);
const result = await fastify.prisma.$transaction((tx) => employeeLenders.refreshEmployeeLender(tx, fastify.config, request, id, body));
return sendData(reply, request, mapLender(fastify.config, result));
});
fastify.get("/loan-lenders", auth, async (request, reply) => {
ensureLoanManager(fastify, request);
@@ -177,9 +201,11 @@ async function companyLoansRoutes(fastify) {
const lender = await lockLender(tx, id);
assertCurrentVersion(lender.identity_version, body.expectedVersion);
await assertLenderNotInFlight(tx, id);
await employeeLenders.lockEmployeeIdentities(tx, [lender.employee_id, body.employeeId]);
if (body.employeeId && !await tx.employee.findFirst({ where: { id: body.employeeId, status: "active", archived_at: null } })) {
throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
}
await employeeLenders.assertUniqueEmployeeLender(tx, body.employeeId, lender.legal_entity_id, id);
const result = await tx.loanLender.update({ where: { id }, data: {
employee_id: body.employeeId, identity_status: "verified", identity_version: { increment: 1 },
identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(),
@@ -201,6 +227,7 @@ async function companyLoansRoutes(fastify) {
const lender = await lockLender(tx, id);
assertCurrentVersion(lender.account_version, body.expectedVersion);
await assertLenderNotInFlight(tx, id);
if (lender.employee_id) throw buildBusinessValidationError("loan.employee_account_managed", {}, 409);
await validateLenderReferences(tx, { ...body, legalEntityId: lender.legal_entity_id });
const result = await tx.loanLender.update({ where: { id }, data: {
bank_institution_id: body.bankInstitutionId, bank_name: body.bankName, ...encryptLenderAccount(fastify.config, body),
+201
查看文件
@@ -0,0 +1,201 @@
const { createHash } = require("node:crypto");
const { Prisma } = require("@prisma/client");
const { AppError } = require("../utils/AppError");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow, maskBankAccount } = require("./crypto");
const {
LENDER_INCLUDE, ensureLoanManager, lockLender, assertLenderNotInFlight,
assertCurrentVersion, encryptLenderAccount, readLenderAccount, mapLender, auditLoan, inFlightLenderIds,
} = require("./companyLoans");
function ensureEmployeeBankAccess(fastify, request) {
ensureLoanManager(fastify, request);
if (!fastify.hasPermission(request.employee, "employee.bank.read") &&
!fastify.hasPermission(request.employee, "employee.bank.manage")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
// Existing-lender actions always acquire the lender before these personnel locks.
// Creation has no existing lender to lock and must never acquire one afterwards.
// Non-key updates still serialize source changes and competing associations,
// while allowing audit/actor foreign keys to acquire their implicit KEY SHARE.
async function lockEmployeeIdentities(tx, ids) {
const unique = [...new Set(ids.filter(Boolean))].sort();
if (unique.length) await tx.$queryRaw`SELECT id FROM employees
WHERE id IN (${Prisma.join(unique.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY id FOR NO KEY UPDATE`;
}
async function linkedLenders(db, employeeId, legalEntityId) {
// Archived or ambiguous historical links also block a second registration.
return db.loanLender.findMany({ where: { employee_id: employeeId, legal_entity_id: legalEntityId },
include: LENDER_INCLUDE, orderBy: { id: "asc" }, take: 2 });
}
async function assertUniqueEmployeeLender(tx, employeeId, legalEntityId, lenderId) {
if (!employeeId) return;
const links = await linkedLenders(tx, employeeId, legalEntityId);
if (links.some((lender) => lender.id !== lenderId)) {
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
}
}
async function readEmployeeSource(db, config, employeeId, legalEntityId) {
const employee = await db.employee.findUnique({ where: { id: employeeId }, select: {
id: true, name: true, employee_no: true, legal_entity_id: true, status: true, archived_at: true, updated_at: true,
bank_name: true, bank_institution_id: true, bank_account_encrypted: true,
legal_entity: { select: { name: true } },
bank_institution: { select: { id: true, code: true, name: true, enabled: true, archived_at: true } },
} });
const company = await db.legalEntity.findFirst({ where: { id: legalEntityId, enabled: true, archived_at: null, currency_code: "CNY" } });
if (!employee || employee.status !== "active" || employee.archived_at || !company) {
throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
}
const name = employee.name?.trim() ?? "";
const bankName = employee.bank_name?.trim() ?? "";
const bank = employee.bank_institution;
const problems = [];
if (!name || name.length > 120) problems.push("name_invalid");
if (!bank || !bankName || bankName.length > 200) problems.push("bank_missing");
if (bank && (bank.code !== "CCB" || !bank.enabled || bank.archived_at)) problems.push("bank_unsupported");
let bankAccount = null;
if (!employee.bank_account_encrypted) problems.push("account_missing");
else {
try {
bankAccount = decryptStringOrThrow(config, employee.bank_account_encrypted);
if (!bankAccount) problems.push("account_unavailable");
else if (!/^\d{10,30}$/.test(bankAccount)) problems.push("account_invalid");
} catch { problems.push("account_unavailable"); }
}
// Bind to all source fields, not just the millisecond timestamp. Ciphertext is
// hashed without exposing it or making a plaintext account hash enumerable.
const sourceVersion = createHash("sha256").update(JSON.stringify([
"loan-employee-source-v1", legalEntityId, employee.id, employee.updated_at,
employee.name, employee.legal_entity_id, employee.status, employee.archived_at,
employee.bank_name, employee.bank_institution_id, employee.bank_account_encrypted, bank,
])).digest("hex");
return { employee, company, name, bankName, bankAccount, bankInstitutionId: bank?.id, problems, sourceVersion };
}
function assertUsableSource(source, expectedSourceVersion) {
if (source.sourceVersion !== expectedSourceVersion) {
throw buildBusinessValidationError("loan.employee_source_changed", {}, 409);
}
if (source.problems.length) throw buildBusinessValidationError("loan.employee_source_incomplete", {}, 409);
}
function matchesSource(config, lender, source) {
if (lender.identity_status !== "verified" || lender.name !== source.name ||
lender.bank_institution_id !== source.bankInstitutionId || lender.bank_name !== source.bankName) return false;
try {
const account = readLenderAccount(config, lender);
return account.bankAccount === source.bankAccount && account.accountName === source.name;
} catch { return false; }
}
async function previewEmployeeLender(db, config, employeeId, legalEntityId) {
const source = await readEmployeeSource(db, config, employeeId, legalEntityId);
const links = await linkedLenders(db, employeeId, legalEntityId);
const conflict = links.length > 1 || Boolean(links[0]?.archived_at);
const existing = !conflict ? links[0] : null;
const inFlight = existing ? await inFlightLenderIds(db, [existing.id]) : new Set();
return {
employee: { id: source.employee.id, name: source.name, employeeNo: source.employee.employee_no,
legalEntityId: source.employee.legal_entity_id, legalEntityName: source.employee.legal_entity?.name ?? null },
legalEntityId, legalEntityName: source.company.name, sourceVersion: source.sourceVersion,
bankName: source.bankName, bankAccountMasked: source.problems.some((p) => p.startsWith("account_"))
? null : maskBankAccount(source.bankAccount), problems: source.problems,
linkState: conflict ? "conflict" : !existing ? "new" : matchesSource(config, existing, source) ? "reusable" : "refresh_required",
existingLender: existing ? mapLender(config, existing, { inFlight: inFlight.has(existing.id) }) : null,
};
}
async function linkEmployeeLender(prisma, config, request, input) {
// A link may appear while creation waits on the employee lock. Finish that
// transaction, then reacquire in lender -> employee order; never invert it.
for (let attempt = 0; attempt < 2; attempt++) {
const before = await linkedLenders(prisma, input.employeeId, input.legalEntityId);
if (before.length > 1 || before[0]?.archived_at) throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
try {
const result = await prisma.$transaction(async (tx) => {
const lender = before[0] ? await lockLender(tx, before[0].id) : null;
// Also serialize this actor's request keys across different employees.
await lockEmployeeIdentities(tx, [input.employeeId, request.employee.id]);
const source = await readEmployeeSource(tx, config, input.employeeId, input.legalEntityId);
assertUsableSource(source, input.expectedSourceVersion);
const links = await linkedLenders(tx, input.employeeId, input.legalEntityId);
if (links.length > 1 || links[0]?.archived_at || lender && links[0]?.id !== lender.id) {
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
}
if (!lender && links.length) return null;
const requestBinding = { employeeId: input.employeeId, legalEntityId: input.legalEntityId,
sourceVersion: input.expectedSourceVersion };
const previousRequest = await tx.auditLog.findFirst({ where: { actor_id: request.employee.id,
action: { in: ["LOAN_LENDER_EMPLOYEE_CREATED", "LOAN_LENDER_EMPLOYEE_REUSED"] },
extra: { path: ["employeeSourceRequestKey"], equals: input.requestKey },
}, select: { target_id: true, extra: true } });
if (previousRequest && (previousRequest.target_id !== lender?.id ||
Object.entries(requestBinding).some(([key, value]) => previousRequest.extra[key] !== value))) {
throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
}
const keyed = await tx.loanLender.findUnique({ where: {
created_by_request_key: { created_by: request.employee.id, request_key: input.requestKey },
} });
if (keyed && keyed.id !== lender?.id) throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
if (lender) {
if (!matchesSource(config, lender, source)) throw buildBusinessValidationError("loan.employee_lender_refresh_required", {}, 409);
if (!previousRequest) await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REUSED", lender.id, {
...requestBinding, employeeSourceRequestKey: input.requestKey,
});
return { lender, reused: true };
}
const created = await tx.loanLender.create({ data: {
legal_entity_id: input.legalEntityId, employee_id: input.employeeId, name: source.name,
bank_institution_id: source.bankInstitutionId, bank_name: source.bankName,
...encryptLenderAccount(config, { bankAccount: source.bankAccount, accountName: source.name }),
identity_status: "verified", identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(),
identity_confirmation: "财务已核对所选员工、借款所属公司及员工收款资料",
created_by: request.employee.id, updated_by: request.employee.id, request_key: input.requestKey,
}, include: LENDER_INCLUDE });
await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_CREATED", created.id, {
...requestBinding, employeeSourceRequestKey: input.requestKey,
});
return { lender: created, reused: false };
});
if (result) return result;
} catch (error) {
if (error?.code === "P2002") throw buildBusinessValidationError("loan.duplicate_registration", {}, 409);
throw error;
}
}
throw buildBusinessValidationError("loan.employee_lender_conflict", {}, 409);
}
async function refreshEmployeeLender(tx, config, request, id, input) {
const lender = await lockLender(tx, id);
assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion);
assertCurrentVersion(lender.account_version, input.expectedAccountVersion);
await assertLenderNotInFlight(tx, id);
if (!lender.employee_id || lender.archived_at) throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
await lockEmployeeIdentities(tx, [lender.employee_id]);
await assertUniqueEmployeeLender(tx, lender.employee_id, lender.legal_entity_id, lender.id);
const source = await readEmployeeSource(tx, config, lender.employee_id, lender.legal_entity_id);
assertUsableSource(source, input.expectedSourceVersion);
const result = await tx.loanLender.update({ where: { id }, data: {
name: source.name, bank_institution_id: source.bankInstitutionId, bank_name: source.bankName,
...encryptLenderAccount(config, { bankAccount: source.bankAccount, accountName: source.name }),
identity_version: { increment: 1 }, account_version: { increment: 1 }, identity_status: "verified",
identity_confirmed_by: request.employee.id, identity_confirmed_at: new Date(), identity_confirmation: input.reason,
updated_by: request.employee.id,
}, include: LENDER_INCLUDE });
await auditLoan(tx, request, "LOAN_LENDER_EMPLOYEE_REFRESHED", id, {
employeeId: lender.employee_id, sourceVersion: source.sourceVersion,
identityVersion: result.identity_version, accountVersion: result.account_version, reason: input.reason,
});
return result;
}
module.exports = {
ensureEmployeeBankAccess, lockEmployeeIdentities, assertUniqueEmployeeLender,
previewEmployeeLender, linkEmployeeLender, refreshEmployeeLender,
};
@@ -1,4 +1,9 @@
{
"loan.employee_source_incomplete": "员工姓名或收款资料不完整、不可用或银行不受支持,请在员工资料中核对维护。",
"loan.employee_source_changed": "员工资料已变化,请重新加载并核对后再保存。",
"loan.employee_lender_conflict": "该员工在本公司存在重复、归档或已变化的出借人关联,请核对原记录,不能另建或自动合并。",
"loan.employee_lender_refresh_required": "已有出借人资料与当前员工资料不同,请核对后使用“使用当前员工资料”更新。",
"loan.employee_account_managed": "员工出借人的收款资料来自员工档案,请先维护员工资料,再使用“使用当前员工资料”更新。",
"loan.related_authorization_required": "本次操作没有有效的关联方公司授权,请核对授权范围和当前付款或对账权限。",
"loan.related_scope_invalid": "关联方授权、身份、借款范围或历史决定不一致,请刷新并核对;不能转换原审批模式。",
"loan.related_actor_conflict": "关联方确认须由指定公司有权人完成,且不能兼任本批经办、上传、录入或提案人。",