diff --git a/.trellis/spec/hyb-oa/backend-fastify-prisma.md b/.trellis/spec/hyb-oa/backend-fastify-prisma.md index 5eebe893..9d643d88 100644 --- a/.trellis/spec/hyb-oa/backend-fastify-prisma.md +++ b/.trellis/spec/hyb-oa/backend-fastify-prisma.md @@ -1145,6 +1145,12 @@ await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id); - `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. Manual account changes revoke identity confirmation and invalidate old unpaid snapshots; explicit employee-source refresh renews confirmation under the checks below. +- `LoanLenderAcknowledgement` is the lender's declaration, separate from company-representative authority. Finance proposes 1–50 unconfirmed loans for one verified employee lender/company, freezing principal, opening balance/date, incoming originals and identity versions. `loan.acknowledge_own` permits only the frozen employee's own records/originals/decision; manager or administrator status cannot substitute for that identity. Keep the finance maker distinct, originals checked on confirmation, strict request-key/hash binding and audit atomicity. +- Header, scope snapshots and the single confirm/return decision are immutable. SQL triggers invalidate the whole group permanently after any member's material terms, incoming original or lender association/version changes; restoring source values cannot reactivate it. Additional accounting evidence, balance confirmation and ordinary loan version increases do not invalidate. Returned/invalid statements can be reissued; overlapping current pending/confirmed scopes cannot. After balance confirmation, reissue is allowed only with invalidated declaration history for that loan; never rewrite the original balance confirmation, loan version or terms. Service, SQL scope guard and `lenderAcknowledgementRenewable` UI projection must agree. Purely paper-confirmed loans do not gain first-time online declaration scope. +- `assertEvidenceComplete` accepts paper agreement OR a valid confirmed declaration. Incoming originals, accounting reconciliation and required historical-repayment adjustment evidence stay mandatory at balance and repayment consumers. Balance confirmation must lock lender then loan, reread the lender association and evidence, and refuse after a concurrent invalidation; locking only that loan misses sibling-original and identity changes. Declaration writes lock lender → sorted loans → declaration → employee (`FOR NO KEY UPDATE`). Preserve existing payment-batch lock order. +- Declaration-scoped downloads expose only frozen incoming originals after current source and physical digest checks, with purpose, audit and no-store; never expose accounting attachments or other loans via this scope. JSON POSTs through `apiRequestBlob` must explicitly set `Content-Type: application/json`. UI requests are actor-scoped and cancellable; a late 401 cannot replay an old decision under a new account. Ordinary employees have no permanent company-loan entry in workspace navigation, NavLinks fallback or MobileMePage; keep own-only route access independent of navigation visibility. Finance and company representatives retain their existing entries. +- Proposals create one `loan.lender_acknowledgement_requested` in-app notification for the frozen lender employee, in the same transaction as the declaration and audit. Idempotent retries do not create another notification, and a notification write failure rolls back all three. Keep copy free of lender names, amounts, bank details and originals; do not add this type to IM support or external delivery/outbox. Desktop/mobile notification targets use `loan_lender_acknowledgement` and the declaration ID to open details via `lenderAcknowledgementId`, independent of the list page. The URL ID is the single source of current selection and overrides proposal mode; ID changes clear drafts/confirmation state and cancel old requests. Existing ownership and invalidation checks still govern historical links. +- Verify declaration contracts with `RUN_ISOLATED_LOAN_ACK_ONLY=1` on the isolated loan runner and `RUN_ISOLATED_LOAN_BROWSER=1` for desktop/mobile originals, confirm/return, accounting requirement and actor-switch cancellation. Retain full-loan regression. Migration `20261010010000_loan_lender_acknowledgements` adds history without backfilling acceptance; preserve it on rollback. An older application cannot consume online-only agreement evidence, so post-use rollback requires compatibility planning. - 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. diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md index e05d2afd..68e1a67f 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/design.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/design.md @@ -1,4 +1,17 @@ -## 2026-10-09 N5-P 原借入回单识别预填设计(待批准实施) +## 2026-10-10 N5-C 出借人在线确认设计 + +1. 新增专用声明、借款快照及单次决定三张表,记录固定的公司/出借人/关联员工、财务发起人、请求键、日期、金额、原件 ID/摘要和版本。声明与范围在一个事务内完整成立,数据库约束保障范围一致及历史不可改删;失效只允许从有效到无效,保留原决定。 +2. 快照读取数据库当前值,客户端仅提交借款 ID/版本及出借人身份版本。限定同公司同出借人、身份已核实、有入账原件;首次发起限定尚未确认期初余额,已确认余额仅在有失效在线声明历史时开放重发。重发不修改借款版本、金额、截止日、原余额确认人/时间、状态或代账引用。同一有效范围的待确认/已确认记录不重复发起,返回已存在提示;退回或失效后允许新请求。多笔声明是一组,任一关键源变化使该组失效。 +3. 发起/决定按“出借人 → 排序后的借款 → 声明 → 人员”取锁,重读来源;决定必须为冻结的关联员工本人且不是发起人。新请求及决定均有幂等和审计原子性。数据库在借款条款、入账原件或出借人关联/版本变化时自动失效该组,不以普通 `loan.version` 增量推定失效,避免确认余额和补代账材料误伤声明。 +4. 新 API 提供财务全量及本人范围内的声明列表、详情、发起、确认/退回;详情只返回冻结明细及原件元数据。原件沿既有下载校验、用途及审计,新增声明范围绑定,不能借此读取整个台账、代账文件、他人原件或当前关联身份已变化的原件。声明请求和原件引用均不写入浏览器长期存储。 +5. 公司借款页增加“出借人确认”,与“公司代表授权”明确分开。普通员工只读/办理自己的声明,不需要经理身份;根据发布前入口调整,普通员工通过站内通知进入,常驻电脑导航/后备导航/手机“我的”仅供财务和公司代表使用。本人的路由及接口权限保持。沿现有组件/样式实现;查询按账号隔离,新写动作及下载支持取消,账号变化中止旧请求,保留同身份令牌恢复。 + 发起声明事务复用 `createNotifications`,仅为冻结的关联员工创建一条 `loan.lender_acknowledgement_requested` 通知,目标类型为 `loan_lender_acknowledgement`、目标 ID 为声明 ID;通知文本不复制姓名、金额、账号或原件内容。不调用 IM/outbox/企业微信派发,也不加入外部消息支持类型。通知与声明/审计同事务提交,幂等返回不再次通知。电脑和手机通知链接分别指向公司借款页的 `lenderAcknowledgementId` 参数,按账号隔离加载单条详情并直接打开;不依赖其位于列表第一页。URL 为当前详情的单一来源,非空 ID 优先于发起草稿;切换声明或浏览器返回时清除旧草稿、核对状态及错误,并取消旧请求。失效/他人记录继续沿现有详情服务校验,不凭通知恢复权限或效力。 +6. `assertEvidenceComplete` 的协议类依据接受有效声明,余额确认与后续还款消费者统一使用;有效性既看决定与失效状态,也重比当前条款、身份和原件。余额确认先读出借人 ID,再按出借人 → 借款取锁、重读并核对关联,使同组另一笔原件或身份变化与材料消费串行;不能仅锁当前借款后消费旧对象。纸质依据继续可用,原件/代账/必要调整证据不放宽。出借人确认不推进余额、授权、付款状态。 +7. 增量迁移预计从 82 到 83,无存量回填或生产操作。回退保留新增历史表,不回删确认记录;旧应用不认识在线替代材料,线上已消费在线依据后不得声称可无影响回退到旧版,应使用兼容修复或先核实纸质依据覆盖。发布与生产迁移仍另行批准。 + +验证沿现有隔离 PostgreSQL/HTTP/browser runner,新增定向 fixture;真实账号、回单、生产数据和外部服务不用于开发验证。重点验证本人越权、快照不可变、改回旧值不复活、必要材料不放宽、决定/变更并发及审计回滚。完成后启动只读 reviewer;inline 主会话自行实施和检查,不派 implement/check 子代理。 + +## 2026-10-09 N5-P 原借入回单识别预填设计(历史规划记录,已实施发布) ### P-A 现有能力与方案选择 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md index 6c39a118..9320cc7b 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/implement.md @@ -1,4 +1,138 @@ -## 2026-10-10 N5-P 第五轮独立复核通过(待提交授权) +## 2026-10-10 N5-C 发布前入口调整(本地验证及只读复核完成,未提交、未发布) + +用户复核提出普通员工常驻入口无必要,并给出“只对已关联出借人的员工显示”或“发起后通知直达”两种方案、委托本会话取舍。本次选择第二种,沿已批准的本人确认功能收敛入口,复用现有 OA 站内通知。范围包含通知创建和声明定位,不能只删导航;不新增通用待办、外部消息或迁移。 + +- [x] E1:恢复财务/公司代表常驻导航边界,本人路由权限保留;声明发起事务只给对应本人创建一条站内通知。 +- [x] E2:电脑/手机通知链接直接打开对应声明,使用现有按账号隔离的详情与取消请求逻辑,不依赖列表首屏。 +- [x] E3:定向通知幂等/事务回滚/本人可见、导航及电脑手机通知点击验证,补只读复核,更新手册/spec。 +- 发布安排:建议本次生产结构迁移前做腾讯云手工快照,实际快照及发布操作待明确批准;发布候选须只允许 `20261010010000_loan_lender_acknowledgements` 这 1 个待应用迁移,发布后核对 83/83。迁移除新增三表外,还包含历史不可修改/范围完整性约束以及挂到既有借款、出借人、原件表的来源失效触发器;没有历史余额回填,不能简写为“只新增三张表”。当前生产未访问。 +- 两处既有待查:追回到账和员工授权翻页失败根因仍未定,保留失败证据与确定性复现要求,本次不混入根因排查或宣称修复。后续按独立排查范围安排;原完整 r4 的 216 项结果仍为入口调整前证据,不能直接充当修改后回执。 +- 本次仍无提交、推送、部署、生产写入或外部消息授权。 +- 首轮入口定向 `entry-browser-r1.log`:83 迁移、17 组 HTTP 均通过;浏览器已从通知打开声明弹窗,随后原登录 helper 还要求通过可访问角色查询找到弹窗后面的“公司借款”标题,Radix Dialog 正确屏蔽背景后该断言失败。仅将通知直达分支改为断言当前“出借人借款声明”弹窗标题,财务普通登录仍检查页面标题;失败日志保留,随后 `entry-browser-r2.log` 的 17 组 HTTP、16 组电脑/手机操作和 9 组数据库检查通过。 +- 本轮只读复核发现 URL 声明 ID 被发起模式遮挡:打开声明、关闭、再打开发起表单后,浏览器历史返回声明 URL 时仍显示发起。改为非空 URL ID 优先显示详情,并清除发起草稿/确认说明/勾选/错误、取消旧请求。新增电脑/手机同 SPA 场景:非空出借人、已选一笔和说明的草稿 → A → B → 浏览器后退 A → 重开发起,断言明细及草稿清理。复核方补查产品与测试、核对最终 r5 日志,结论 `REVIEWED_NO_BLOCKER`;复核方未改文件、未写库,测试运行结果沿用主会话回执,不冒称独立重跑。 +- `entry-browser-r3.log`:83 迁移、17 组 HTTP 通过,浏览器登录时的通知列表断言失败,未计通过。测试原谓词只匹配 `/api/notifications` 路径,而顶部栏同时请求未读计数列表;该次未采查询参数。`entry-browser-r4.log` 保留原谓词并增加 query/items/mode 的合成诊断,稳定复现捕获 `?page=1&pageSize=1&unreadOnly=1`、只有一条结果的顶部计数响应,确认为测试等待对象歧义。现严格匹配通知页 GET/200、type、page=1/pageSize=20/unreadOnly=0,保留原目标 ID、真实点击及 URL/弹窗断言;未改业务、未增加重试/超时。r3/r4 失败日志和合成截图保留。 +- 最终 `entry-browser-r5.log`:新进程使用最终业务代码和 `client-build-entry-r2.log` 构建,退出 0;83 迁移、17 组 HTTP、18 组电脑/手机操作、9 组数据库检查均通过,测试自有资源清理通过。不累计 r2/r5 重复项目。覆盖通知收件人/幂等/并发/失败回滚、无外部派发、普通员工无常驻入口、实际通知点击定位、上述同 SPA 历史切换、原件下载/本人确认/退回、代账材料门槛、历史失效通知/续办、同身份刷新及跨账号请求取消。 +- `unit-entry-r1.log`:导航 12、权限 14、通知接口 19,合计 45 项通过;最终前端类型检查/构建、服务及 fixture 语法、错误理由和 UI 规则检查通过。任务上下文、流程基线、Trellis 治理、敏感内容及差异检查通过;文档登记 0 错误、保留既有 22 份未登记文档的 1 条告警。手册及 backend spec 已同步站内通知与 URL 选择合同。 +- 本轮只做入口范围定向验证,没有重跑完整借款回归或最终发布门禁。此前完整 r4 的 216 项只能作为入口调整前证据。下一阶段待用户明确授权本地提交,再对最终提交运行完整发布门禁;生产快照、推送、部署及仅 1 个生产迁移仍需各自明确批准。真实 N5/N6、默认值暂停项及历史失败根因待查继续保留。 + +## 2026-10-10 N5-C 出借人在线确认(入口调整前本地实现与验证完成) + +用户已明确批准收窄方案,复用本任务 AC40–AC44。基线为 `2063ff1e`,开始时已有 `prd.md`/`implement.md` 的发布补记及待办未提交,予以保留。仅本功能恢复实施,默认余额/日期待办仍暂停;无提交、推送、部署、生产写入及对外消息授权。 + +- [x] C0:核对材料消费者、借款/身份/附件锁序、本人访问边界,更新 PRD/设计;确认需新增专用声明数据,而非复用公司代表授权。 +- [x] C1:增量迁移、不可变快照/决定、单向失效及服务端本人权限/事务/幂等。 +- [x] C2:财务发起与出借人确认/退回界面、受限访问及原件查看,保留纸质路径。 +- [x] C3:接通余额确认和还款材料判断;隔离 HTTP/数据库并发、电脑/手机及纸质回归。 +- [x] C4:Prisma、后端语法/定向测试、前端类型/构建、错误码/治理检查;独立只读复核并修复阻断项。 +- [x] C5:更新证据与交接,说明本地验证和生产发布边界,不自动提交或部署。 + +### 当前实现与验证记录(本地,最终完整回归 r4 通过) + +- 新增专用出借人声明、借款快照及单次决定三表。本人账号与财务经办分离,声明与公司代表授权分离;快照/决定不可改删,任一来源变化使整组永久失效。服务、触发器及 UI 同时支持“已确认余额且有失效声明历史”的续办,禁止借此改写借款或原余额确认;纯纸质已确认借款不开放首次线上声明。 +- 余额确认与后续还款材料消费者接受纸质协议或有效在线声明二选一,其他材料要求不变。新增第 83 个迁移仅在合成隔离数据库执行;生产仍为 `2063ff1e`/82 个迁移。 +- 独立只读复核:`review_lender_ack` 首轮发现余额确认只锁借款造成并发失效遗漏、原件下载 JSON 类型缺失,两项均修复;第二轮无阻断(浏览器待验)。随后主会话发现已确认余额后的声明续办缺口,复核方确认,按上述最小历史范围修复;最终续办复核为 `REVIEWED_NO_BLOCKER`,最终 `browser-r3.log` 已补齐电脑/手机续办与实际导航验收。复核方未改代码、未写数据库、未读取真实材料。 +- HTTP `http-r3.log`:15 组专项和 9 组既有数据库约束通过,含身份/同组原件并发失效的实际锁等待、事务审计失败回滚、相反决定并发、原件/真实 employee-refresh 后重发、退回重发、部分组续办、原借款整行不变、旧声明不复活、公司授权仍需重办。83 迁移全部应用成功,隔离容器无网络/持久卷。 +- 浏览器最终 `browser-r3.log`:新进程使用最终业务代码与 `client-build-r4.log` 构建,15 组 HTTP、电脑/手机合计 14 组操作及 9 组既有数据库约束全部通过。实际从电脑导航/手机“我的”进入,原件下载 200、本人核对/确认、代账材料拒绝后上传并确认余额、退回重发、已确认余额后的声明续办、同身份令牌刷新、同 SPA 切换账号后旧请求取消且无重发均覆盖。早一轮 `browser-r2.log` 的 10 组仅为中间证据,不与最终 14 组累计。 +- 最终入口补查:普通员工直达页可用,但旧导航的财务角色筛选仍隐藏入口。补齐服务端 workspace role gate、客户端 NavLinks 显示/后备项及手机“我的”链接,例外仅限公司借款及 `loan.acknowledge_own`。最终只读导航复核无阻断;`unit-navigation-r2.log` 的 12 项通过,明确不授予 `loan.manage`,付款/应付入口继续隐藏。早期完整回归启动早于本次导航修复;最终 `full-loan-r4.log` 使用修复后的代码,实际导航点击另由最终专项 `browser-r3.log` 覆盖,不把分轮证据混称一次全量。 +- 单测:`unit-ack-r2.log` 新声明材料/续办 28 项;`unit-related.log` 权限、导航和公司代表授权 64 项通过(导航后续 12 项重验,不重复计数);错误理由 `unit-errors.log` 923 项通过。合计覆盖 1015 项,其中本功能新单测 28 项。前端最终 `client-build-r4.log` 类型检查与构建通过;Prisma validate、错误码合同、UI 规则、语法及差异检查通过。此后仅改测试断言/采证和文档,不重复构建未变的前后端业务代码。 +- 最终完整借款回归 `full-loan-r4.log`:退出 0,83 迁移通过,去重后 216 项(HTTP 150、浏览器 57、数据库约束 9),测试自有资源清理通过。45 项原借款浏览器汇总包含关联方 18 和员工 8,不能再次累加;本功能在完整轮次为 HTTP 15、电脑/手机浏览器 12。专项 `browser-r3.log` 的 14 项比完整轮次多电脑/手机实际导航各一项,作为补充证据列出,不和完整 216 重复累加。 +- 失败保留:`http-r1.log` 未指定已有 BIFF8 Python 导致导出不可用,修正测试环境后 `http-r2/r3` 通过;`browser-r1.log` 新弹窗没有显式关闭按钮,补现有 DialogFooter 按钮后重跑通过;原余额弹窗按既有 Escape 方式退出。实现期间错误码检查曾拒绝动态工厂,已改为显式登记的错误工厂,后续两次合同检查通过。没有放宽业务断言或增加重试掩盖失败。 + +- 完整回归首轮 `full-loan-r1.log` 在既有“登记追回到账”浏览器步骤失败,空 alert 日志不足以确定根因,不宣称旧问题已修复。测试原来按当时选项数量取最后一项,现改为捕获上传响应中的具体 evidenceId、等待该选项并断言选中,同时增加提交前无效控件与失败截图/按钮状态采证;不改业务、不增加重试或超时。旧普通员工整页 403 验收改为仅本人声明可见、台账入口和财务数据请求均不存在,服务端台账 403 仍保留。上述测试调整已由复核方只读检查,无放宽权限/材料断言,但不能据此证明首轮失败根因。 +- 完整回归 `full-loan-r2.log` 仅记录迁移、员工 HTTP 和 S4 阶段后进程被终止,退出码 143,无完整结果,终止原因未定,不计通过。 +- 完整回归 `full-loan-r3.log` 在关联方电脑浏览器的普通技术员工 `waitForURL(/403/)` 处超时。失败采证 `artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-REBg2n/failed-desktop-2.json` 显示员工已在公司借款页、仅看到本人声明,根因是旧“普通员工整页 403”断言尚有一处漏改。只调整 `company-loan-related-browser.cjs`:本人声明空列表可见、无财务/授权操作及 tablist,登录前开始监测并断言零台账/出借人/还款/公司授权接口请求;保留技术员工无还款通知和公司授权停止、服务端越权 403 检查。复核方补查确认未削弱财务/公司授权隔离,无阻断项;完整 `full-loan-r4.log` 随后通过。本轮为不同的确定性测试合同过期,不据此关闭首轮追回到账或既有员工授权翻页根因待查项。 + +### 收尾与交接边界 + +- 本功能 AC40–AC44 为本地合成验收完成,父任务仍为 `in_progress`。真实 N5/N6、既有翻页问题和暂停的登记默认值待办仍开放;首轮追回到账失败原因为未定,r4 通过不代表已查清。 +- 操作顺序已写入现有《试运行与自动化验证手册》:财务发起 → 出借人本人核对原件并确认/退回 → 财务补齐代账材料及必要的已还本金依据、确认期初余额 → 单独办理公司代表授权。只替代协议类材料,不自动确认余额或批准付款。 +- 收尾任务上下文、流程基线、Trellis 治理、敏感内容、文档登记及差异检查通过;文档登记仍为 0 错误和既有 22 份未登记文档的一条告警,不扩展治理范围。回执位于本证据目录的 `*-closeout*.log`。 +- 未运行全服务端测试套件或最终发布门禁;本轮结果不能代替后续最终提交的发布候选验证。没有提交、推送、部署、生产迁移或真实业务写入;新增第 83 个迁移仅本地验证,发布仍需单独授权,生产仍为已发布的 `2063ff1e`/82 迁移。 + +证据目录:`artifacts/qa-flow/loan-lender-ack-20261010/`。复现命令(`PAYMENT_ORDER_PYTHON_BIN` 使用本机已备好的锁定 BIFF8 环境,不提交真实路径/凭据): + +```sh +cd server && npm run prisma:validate +npm test -- tests/unit/services/loanLenderAcknowledgement.test.js +npm test -- tests/unit/services/loanApprovalPolicy.test.js tests/unit/constants/permissions.test.js tests/unit/workspaceSettings.navigation.test.js tests/unit/permissions.entity.test.js +cd ../client && npm run build +``` + +根目录专项:`RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_ACK_ONLY=1 node scripts/qa/test-company-loan-db.cjs`,电脑/手机另加 `RUN_ISOLATED_LOAN_BROWSER=1`;完整借款回归移除 `RUN_ISOLATED_LOAN_ACK_ONLY`。不使用真实用户、回单、云 OCR 或生产连接。 + +## 2026-10-10 真实办理卡点:历史借款缺少协议和余额核对附件(历史反馈,后续方案见上节) + +用户回报财务已完成录入,随后在“借款材料与余额确认”页面反馈所需材料没有。截图显示原借入凭据已有附件,“协议或借款确认书”“余额核对材料”“系统外已还本金依据”栏目未见附件。本次未访问生产,不保存截图中的原始文件名或银行信息;该记录是用户办理反馈,不宣称已独立验收真实台账。 + +- 当前规则核实:确认期初余额必须有协议/确认书、原借入凭据、余额核对材料三类有效附件;仅在未还本金少于原始本金时才额外要求系统外已还本金依据。不能把页面列出四种上传入口解释为所有借款都必须上传四份材料。 +- 实际卡点:已有银行回单,但缺少协议/确认书及余额核对附件,现行流程无法完成余额确认,后续公司代表授权范围也无法选择尚未确认余额的借款。N5 为“用户回报已录入,余额及授权尚待完成”,不再笼统记为尚未录入。 +- 待讨论的改进方向:为无历史协议的真实借款提供当下形成的借款事实与余额确认记录;可评估一份记录同时覆盖借款性质、原件引用、截至日余额和核对依据,或在 OA 内形成等效的具名确认与审计。不要求补造当年协议或把银行入账回单视为未还本金证明。代账核对是否已发生、如何留存引用以及与公司代表确认的分工,须在方案中明确,不能直接将缺件校验删除。 +- 状态:沿用用户“先记录问题,先不要修改”的指令。本轮只补任务记录,不改产品规则、不执行真实确认、不补写虚构材料,待用户决定后续处理方式。 + +## 2026-10-10 待改进:借入登记默认值(仅记录,暂不实施) + +用户在登记页面提出:“登记时应该默认未还金额为全额,日期为当日”,随后明确要求“先记录问题,先不要修改”。本次只读检查了现有入口,没有修改业务代码或测试脚本,也没有运行业务测试。 + +- 当前问题:截图所示“上传回单登记借入”的“核对日未还本金”和“余额核对截止日”没有默认值,需要重复填写;原手工“登记借入”入口也没有这两项默认值。 +- 期望行为:未还本金默认等于本笔原始本金全额;余额核对截止日默认登记当天,按项目北京时间口径。两项仍可人工修改。 +- 日期范围:此处“当日”指余额核对截止日,不替换回单上的实际到账日期。 +- 后续实施需保持:人工修改的余额和截止日不被后续识别覆盖;换回单或重新登记时避免沿用上一笔余额;默认值不代表余额已核实,保存后的“余额待核实”和原有确认流程继续保留。 +- 状态:仅登记待办,暂不修改产品;收到用户明确恢复实施的指令后再处理。两个登记入口的一致性及针对性验证届时一并核对。 + +## 2026-10-10 发布后的后续工作(待执行清单) + +本次按用户要求梳理,复用当前任务,不新建计划系统。产品当前版本为 `2063ff1e`;下表是工作安排,不代表已执行真实录入、付款、技术修复、提交、清理或归档。发布记录及本清单仍未提交,父任务保持 `in_progress`。 + +| 顺序 | 工作与负责人 | 启动条件及完成标准 | +| --- | --- | --- | +| 1:业务主线 N5 | 财务登记真实台账,马经理确认公司代表授权 | 财务从员工中选择并创建/复用出借人,分别上传两份历史借入回单;核对公司、双方姓名/账号、金额、到账日期、流水号及出借人建议,补未还本金与截止日,完成两项确认。原件随借款保存,随后补协议、余额核对材料及代账引用,确认期初余额;登记已有公司代表权及覆盖范围,由马经理确认。完成标准为两笔台账与原件、余额依据及有效授权均可追溯,不新增虚假现金流。真实录入由有权人员办理,代理代录另按授权执行。 | +| 2:技术排查,可与 N5 并行 | 开发确定性复现授权对话框翻页超时 | 复用本任务,先形成局部排查方案。隔离环境构造超过 30 个符合列表条件的出借人,明确目标位于第二页或以后;电脑/手机均断言实际翻页、请求和选项渲染完成,再选择目标并完成授权。留存失败响应、时序及脱敏截图,区分等待竞争、服务端响应和界面问题。仅修改等待方式或未触发该分支的通过不能结案;若实际办理遇到,提升优先级。 | +| 3:业务主线 N6 | 财务申请、网银提交及代账交接;马经理按公司代表身份确认 | N5 完成后,按真实付款安排明确本次金额、逐借款分配及日期;申请 → 确认 → 导出 → 实际网银提交并登记提交详情 → 正式付款回单核验/必要人工确认 → 核销 → 代账交接。以本次已付本金和可定位的银行交易为验收依据,不从余额推定全额还款,不为测试制造付款。扫描付款回单按付款路径的人工依据与确认规则办理。 | +| 4:技术优先项 | 开发评估将回单重检的文件读取/PDF 解析移到全局锁外 | 沿 N3 已记录的 P3 优先顺序设计;锁内保留原件摘要、版本、权限及全部状态重验。用重检与认领/人工核验/执行并发、附件作废/换版等反例证明不使用过期结果,再独立只读复核。工作线程限时 5 秒不是整个持锁时长的上界;不能只凭耗时变短判定修复完成。 | +| 5:技术近期项 | 开发验证员工来源版本的误报,再评估数据库唯一约束 | 来源指纹包含员工 `updated_at`,登录会更新认证字段;先验证“财务预览后员工登录再保存”的冲突,不泛称每次登录必然提示资料变化。修正时须同时保留姓名、公司、状态和银行资料真实变更的拦截。唯一性当前由代码检查与员工行锁保护、缺少员工和公司组合的数据库唯一约束;新增约束前明确归档/空员工语义并检查存量,不直接加迁移或清理真实记录。 | +| 6:线上验收补证 | 运维准备账号与安全凭据来源,财务/马经理补借款角色验收 | 现有通用 live smoke 需要管理员、员工两类账号;凭据通过受控本地环境或既有秘密配置提供,不写聊天和任务记录。先确认检查范围与授权,再执行登录后只读页面/权限检查。通用 smoke 不覆盖本次借款登记及公司代表确认,后者随 N5/N6 留证。真实云 OCR 单列授权和验收;带 OCR 的准备脚本会上传材料,不能当纯只读 smoke 执行。 | +| 7:较低优先级改进 | 开发与财务明确旧记录重检、记账日期、字段提示规则 | 旧记录缺少原云端限制字段,重检不能重建未保存证据,先明确策略再实施;记账日期下限先区分正常还款与历史补记,不直接统一硬卡审批日;第五轮 N1 可统一 OCR 与本地无效编号的提示,但保持整段校验、多页关联和冲突拦截不变。 | +| 8:文档收尾 | 开发提交发布记录及本次清单 | 内容与检查已具备,可在收到明确本地提交授权后单独提交;提交说明引用当前 task。此次梳理不自动提交,也不再次部署。 | +| 9:下轮完整门禁前 | 开发盘点磁盘,用户确认删除范围 | 本次只读统计:`artifacts/` 约 8.87 GiB,所在磁盘剩余约 9.71 GiB。先列候选目录、大小、可再生依据及被证据引用情况;优先评估可再生构建/缓存,不因“旧轮次”就删除。保留发布/回滚依据、有效回执、复核报告、探针、关键失败证据和原件;删除另获明确授权。 | +| 10:维护事项 | 逐个任务的负责人核对后归档 | 当前非归档 task 共 25 个:22 个 `in_progress`、3 个 `planning`,不是 24 个进行中。逐个核对交付、真实业务验收、未决项及记录,再列归档候选;时间久或代码已部署均不代表任务完成。本借款任务当前不归档,其他任务不在本轮批量更改。 | + +验收口径:首笔正常成功还款只能补齐实际覆盖的银行提交、付款核验、核销及交接证据,不能一次关闭 AC5 或 AC9–AC19 的全部分支。失败归属、未付款释放、晚到成功、追回及补证恢复继续按各自条件验收;AC12 人工确认未提交仅适用于双独立复核模式,不适用于当前关联模式。不得为覆盖这些分支制造真实异常交易。 + +翻页问题继续标记“根因未定”。本次未核实生产出借人数量;即使目标当前在第一页,也只能说明这次未触发翻页,不能据此证明线上无影响。N5 可按既有流程由财务推进,实际遇阻时停在对应步骤留证,不绕过余额或授权确认。 + +本次为任务安排与证据边界澄清,无产品变更或新的已验证工程合同;不扩写 spec,不重跑全套业务测试。 + +## 2026-10-10 N5-P 已发布至生产(2063ff1e) + +“上传回单登记借入”已随 `2063ff1e9336b8559ddd506f8f3fd754c1108327` 发布。本次仅补记发布结果:核对本地 Git 与已有日志,没有重新连接生产、调用云 OCR、写入真实业务数据或执行部署。父任务继续保持 `in_progress`;下方“待提交”“未发布”等内容保留各轮历史时点,以本节为最新状态。 + +### 本地核实的发布证据 + +- 补记前工作区干净,HEAD 与本地 `origin/main` 均为 `2063ff1e9336b8559ddd506f8f3fd754c1108327`。部署日志记录相同来源提交、`branch=main`、`published=true`、`remoteVerified=true`。 +- `artifacts/qa-flow/release-2063ff1e-20261010/verify-candidate.log`:候选验证结果 `status=PASS`、`localChecksPassed=true`、`deploymentEligible=true`、`issues=[]`、退出码 0;含静态检查、完整本地门禁及 372 项浏览器测试通过。本节转录已有验证结果,没有重跑这些测试。 +- 同目录 `deploy.log`:部署前聚合体检 `PASS=13 FAIL=0`;应用构建与切换、external deps smoke、runtime smoke 通过;公网前端资源扫描 `buildMarker=PASS`、`findings=0`。最终为“应用发布与备份合同刷新完整通过”、deploy 模式 PASS、退出码 0。 +- `artifacts/production-preflight-20261010-112903/{pre,post}/`:部署前后迁移核对均 `pending=none`,Prisma 均发现 82 个迁移且数据库结构为最新;当次回滚窗口均为 `OPEN`。该状态仅代表发布检查时点。 +- 部署日志明确跳过登录后的 live smoke,原因是未提供完整凭据。external deps smoke 中 OCR 项仅验证配置齐全,不代表真实云 OCR 识别已验收。 + +### 发布会话转交结果 + +用户回报:`origin/main` 从 `45737ccb` 推送至 `2063ff1e`,部署一次跑通、SSH 未断、前后复核均 13/13;公网 `/ready` 返回 `ok:true`、版本 `2063ff1e9`,首页、`/login`、`/mobile`、`/payment-batches`、电脑和手机公司借款入口均返回 200,前端主脚本正常加载;新识别接口未登录返回 401。 + +以上公网路由和部署后 13/13 明细沿用发布会话回报,本次未重新请求公网。路由可达、版本检查和未登录拦截不等于登录后的真实业务闭环通过。 + +### 财务交接与仍开放事项 + +- 财务在“公司借款”选择“上传回单登记借入”,上传 PDF 后逐项对照原件核对预填值及出借人建议,尤其姓名、账号和流水号;补填未还本金、核对截止日并勾选两项确认后保存。借款与原件一起保存,状态为“余额待核实”。 +- 后续仍需补协议、余额核对材料及代账引用,确认余额,再登记公司代表授权并由马经理确认。上线不代表真实台账、授权或首笔还款已经办理;AC5 及 N5/N6 真实业务验收保持开放,真实写入与付款按各自授权执行。 +- 两份真实回单已在本机解析验证,姓名、账号、流水号尚未逐值独立比对;真实云 OCR 未验收。保存前人工核对不能省略。 +- 员工授权翻页超时根因未定。r8–r11 虽通过,但未确定性触发原失败分支,不能据此断言已修复、仅为测试脚本问题或已证明不影响线上。 +- 第五轮独立复核的非阻断观察 N1(OCR 无效编号提示为 `missing`,本地为 `invalid`)保留,不影响既有拦截结论。 + +### 本次补记验证 + +仅修改本任务 `prd.md` 与 `implement.md`,未改产品代码。任务上下文校验、Trellis 治理、流程基线与 `git diff --check` 通过;文档登记审计为 0 错误、1 条既有警告(22 份未登记文档),本轮未修改 `docs/`。未重跑业务测试。补记尚未提交,不改变已部署产品版本或父任务状态。 + +## 2026-10-10 N5-P 第五轮独立复核通过(发布前历史记录) 第五轮复核(`independent-review-r5-20261010.md`)结论为 PASS_WITH_NOTES:此前要求修改的问题均可关闭,没有阻断项。复核方独立验证: - 相关单测 100 项通过; @@ -7,7 +141,7 @@ 非阻断观察 N1:OCR 读出无效流水号或凭证号时,值会被拒绝,但字段提示显示为 `missing`(未识别);本地读出同类无效值时显示为 `invalid`。两种情况都不会预填错误值,也不会绕过多页拦截。本轮不改代码,下节“无效编号记 invalid”的说法仅适用于本地文字层。 -AC39 的独立复核部分已完成。下面几项仍需另行处理或单独授权: +AC39 的独立复核部分在该时点已完成。当时下面几项仍需另行处理或单独授权(最新进展见顶部发布记录): - 提交、最终候选完整门禁、推送和部署; - 真实回单上的姓名、账号、流水号逐值核对,由财务保存前对照原件完成; - 员工授权翻页超时(根因未定)。 diff --git a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md index f037fb7a..98992386 100644 --- a/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md +++ b/.trellis/tasks/09-17-company-loan-principal-repayment/prd.md @@ -1,12 +1,32 @@ # 公司借款台账与本金归还 -## 2026-10-09 N5-P 原借入回单识别预填(2026-10-10 第五轮独立复核通过,待提交授权与最终门禁) +## 2026-10-10 N5-C 出借人在线确认借款(用户已批准实施) + +用户批准将“借款确认书”增加为 OA 内可追溯的出借人声明,范围仅限本功能。此前“先记录、不修改”对本功能的限制由本次批准取代;登记默认值调整仍只记录、不实施。线上记录是本系统接受的借款确认依据,不宣称仅凭账号操作留痕即当然取得与纸质签字相同的法律效力。 + +- 财务可对同一公司、同一员工出借人的一笔或多笔尚待核实借款发起确认;已确认余额但存在失效在线声明历史的借款允许重新发起。固定借入日期、原始本金、截至日未还本金、原件摘要及对应身份。保留现有纸质协议/借款确认书上传路径;非员工出借人沿纸质路径办理。 +- 只有出借人关联的在职员工本人能查看自己的声明范围、核对原件并确认或退回;发起财务不能代确认,其他员工、管理员或公司代表权限均不能替代本人身份。此动作不要求出借人具有经理/CEO 权限,也不赋予其财务台账或他人借款的读取能力。 +- 确认记录、明细和决定不可改删。借款关键金额、日期、原件或出借人身份关联变化使旧确认失效,改回旧值也不复活;新增余额核对等其他材料不导致无关失效。退回后财务可重新发起。 +- 确认余额以及后续材料完整性检查接受“纸质协议/借款确认书 **或** 有效在线出借人确认”;原借入凭据、代账余额核对材料和必要的系统外已还本金依据仍必需。出借人声明不自动确认余额,不代替公司代表授权或付款批准。 +- 不包含通用电子签章、生成协议正文、替代代账核对、登记默认值修复、生产写入/发布或发送外部通知。财务可继续线下准备确认书及代账材料,不依赖开发完成。 +- 2026-10-10 发布前入口收敛:根据用户给出的两种方案及委托取舍,选择 OA 站内通知入口。普通员工不显示常驻“公司借款”导航;财务及公司代表保留入口,本人确认权限和直达路由继续有效。财务发起时仅为对应出借人创建站内通知,点开直接查看该次声明;不纳入通用待办或外部消息推送。 + +验收: + +- [x] AC40(本地合成验收):财务可对一笔/多笔同公司同出借人借款固定快照并发起;跨主体、未核实身份、缺原件、重复及错误重试有明确结果,审计失败全部回滚。 +- [x] AC41(本地合成验收):本人可在电脑和手机查看快照/原件并确认或退回;财务代确认、他人越权读取/下载/决定、自发自确认和迟到请求跨账号重发受阻。 +- [x] AC42(本地合成验收):确认与快照不可改删;关键字段/原件/身份变化后旧证据永久失效,确认与变更并发不会消费过期结果,变更再还原不复活旧记录;余额确认后可重发失效声明,不改既有余额确认事实。 +- [x] AC43(本地合成验收):在线确认只替代协议类材料,代账和原件仍必需;有效在线记录可实际走到余额确认、既有公司代表授权及还款申请,纸质路径回归通过。 +- [x] AC44(本地验收与交接完成):新增迁移、HTTP/数据库并发与电脑手机验证、独立只读复核及发布边界据实记录;完整借款回归 r4 去重 216 项通过,前期失败及未定根因保留;尚未提交或发布,不宣称线上已可使用。 +- [x] AC45(入口调整,本地合成验收及只读复核完成):普通员工的电脑导航/后备导航/手机“我的”均无常驻借款入口;财务发起只为该出借人创建一条站内通知,同请求重试/并发不重复,事务失败不残留通知;电脑和手机点击通知直接打开该声明并仍执行本人校验,历史或失效通知不恢复旧确认效力。同 SPA 切换声明/浏览器返回正确定位详情并清空旧草稿。财务/公司代表入口、本人权限及账号切换请求保护不退化。最终入口专项 r5 为 17 HTTP、18 浏览器、9 数据库检查通过,复核无阻断;未提交或部署,尚未重跑最终发布门禁。 + +## 2026-10-09 N5-P 原借入回单识别预填(2026-10-10 已发布 2063ff1e,真实业务验收待办理) ### 目标与依据 -用户在办理 N5 时提出先识别凭证或流水、再自动填写,并明确要求“规划改进任务”。本阶段复用当前借款任务,减少已持有原借入回单时重复录入金额、日期、凭据号及再次上传同一文件的操作。此次仅授权规划;原任务 `in_progress` 和此前员工选择功能的实施授权不代表本阶段已获实施批准。 +用户在办理 N5 时提出先识别凭证或流水、再自动填写,并明确要求“规划改进任务”。本阶段复用当前借款任务,减少已持有原借入回单时重复录入金额、日期、凭据号及再次上传同一文件的操作。初始授权仅限规划;此后用户批准完整方案,已完成实施、五轮独立复核及发布,详见 implement 顶部记录。真实业务录入、云 OCR 与付款仍按各自授权和验收边界执行。 -当前页面先手填登记借入、保存后再上传材料(`client/src/pages/CompanyLoansPage.tsx:176`、`:121`);服务端创建借款和上传凭据是分开的动作(`server/src/routes/companyLoans.js:262`、`:314`)。现有本地 PDF 工作者能读取文字,但对外只返回凭证分类;现有银行 OCR 归一化未包含到账日期和双方名称(`server/src/services/paymentReceiptDocument.js:101`、`server/src/services/paymentReceiptMatching.js:41`)。本阶段需要新增借入预填合同,不能把现有付款核验通过条件直接用于借入。 +规划时页面先手填登记借入、保存后再上传材料(当时的 `client/src/pages/CompanyLoansPage.tsx:176`、`:121`);服务端创建借款和上传凭据是分开的动作(当时的 `server/src/routes/companyLoans.js:262`、`:314`)。当时本地 PDF 工作者能读取文字,但对外只返回凭证分类;银行 OCR 归一化未包含到账日期和双方名称(当时的 `server/src/services/paymentReceiptDocument.js:101`、`server/src/services/paymentReceiptMatching.js:41`)。本阶段因此新增借入预填合同,不能把付款核验通过条件直接用于借入。 ### 范围与要求 @@ -21,7 +41,7 @@ 整份银行流水/Excel 批量导入、多交易拆分、自动建立借还对应、自动会计凭证、银行验真、自动确认余额/授权/付款、新银行或 OCR 提供商、图片直传识别、历史记录批量重识别。既有图片附件上传不受影响。规划和本地合成验收不包含真实票据调用云 OCR、生产配置、生产录入或发布。 -验收新增 AC32–AC39,技术合同及执行清单分别见 design、implement 顶部 N5-P。以上为待批准的完整提案,暂无需单独澄清的阻塞问题;用户批准后才进入实施。 +验收新增 AC32–AC39,技术合同及执行记录分别见 design、implement 顶部 N5-P。上述方案已实施发布;本地合成验收、真实样本解析、云 OCR、公开发布和真实业务办理分别记录,不互相替代。 ## 2026-10-09 N5 员工出借人选择调整(本地验证完成,未发布) @@ -227,7 +247,7 @@ R3 的模式同时作用于付款审批、提交清单、成功/失败归属、 - [x] AC36(保存一致性):保存成功同时存在一笔借款及对应 `incoming` 原件;相同请求重试仅返回原结果,换文件/字段复用请求键被拒;同公司同出借人的重复引用、相同原件及并发新入口保存不会生成重复可用借款。存储、附件或审计失败时正确补偿,不虚报成功。 - [x] AC37(身份与原件绑定):无财务权限、跨身份结果复用、令牌过期/篡改、确认前换文件及来源变更受阻;电脑/手机换文件、卸载及同文档换账号时取消旧请求,迟到响应不覆盖新表单或以新身份重发保存;保留同身份正常会话恢复。 - [x] AC38(原流程兼容与隐私):原手工登记、员工关联、非员工登记、三类材料余额确认、关联授权与还款路径仍通过相关回归;原件下载沿既有权限/用途/审计,无完整账号或原始识别 payload外泄。PDF/文件不安全或超限在持久化前拒绝。 -- [x] AC39(证据边界;第五轮独立复核 PASS_WITH_NOTES,真实样本仅确认字段已提取、未逐值核对,云端 OCR 与发布另行验收):完成单元、真实隔离 HTTP/数据库及电脑/手机浏览器验证,覆盖失败、并发和清理;完成涉及入口权限/保存流程的独立只读复核,受 inline 限制时明确待独立复核,不冒用旧结论。真实样本与云 OCR 可用性、公开发布分别验收,不以合成结果勾 AC5 或 N5/N6 实际业务完成。 +- [x] AC39(证据边界;第五轮独立复核 PASS_WITH_NOTES;2063ff1e 已发布,发布日志已核对、公网路由检查沿用发布会话回报;真实样本的姓名、账号、流水号未逐值独立核对,真实云 OCR 和登录后真实业务尚未验收):完成单元、真实隔离 HTTP/数据库及电脑/手机浏览器验证,覆盖失败、并发和清理;完成涉及入口权限/保存流程的独立只读复核,受 inline 限制时明确待独立复核,不冒用旧结论。真实样本与云 OCR 可用性、公开发布分别验收,不以合成结果勾 AC5 或 N5/N6 实际业务完成。 ## Out of Scope @@ -242,11 +262,15 @@ R3 的模式同时作用于付款审批、提交清单、成功/失败归属、 ## Current Status -当前工作为顶部 N5-P“原借入回单识别预填”的规划,PRD、设计及执行清单已形成,待用户批准实施。本轮没有修改产品代码、启动任务执行或进行真实业务登记;父任务继续保持 `in_progress`,不把此前授权套用于本阶段。 +N5-P“原借入回单识别预填”已实施,经第五轮独立复核 PASS_WITH_NOTES 后,随 `2063ff1e` 于 2026-10-10 推送部署。补记时本地 HEAD 与 `origin/main` 一致;已有候选验证日志 PASS,部署日志确认应用发布与备份合同刷新完整通过,部署前后无待应用迁移、仍为 82 个。公网 `/ready` 版本、页面 200 和识别接口未登录 401 沿用发布会话回报,本次只核对本地版本及日志,未重新访问生产。登录后的 live smoke 在部署日志中明确跳过,真实业务办理尚未验收。 + +本轮只补任务记录。财务保存前仍需逐项核对原件并完成两项确认,尤其姓名、账号、流水号;真实云 OCR 未验收。员工授权翻页超时继续标记为根因未定,不以多轮回归通过推定已修复或已排除线上影响。详细证据和交接步骤见 implement 顶部。 员工选择阶段 AC28–AC31 已本地验收。用户在上一会话回报 `45737ccb` 已推送部署、公网检查正常、生产仍为 82 迁移,并明确尚未在线上登录办理新入口;本会话只核对本地版本和转交记录,未重新连接生产或复跑发布检查。下方及前述 `beb9f02c`、“未发布”等记录保留其历史时点,不能作为 N5-P 的验证证据。 -整体任务保持 `in_progress`。真实 OA 台账及关联授权尚未登记,首笔真实还款的网银提交、正式回单、核销及会计交接尚未验收;AC5 及未覆盖的真实材料边界继续开放。无需重新索取已确认的借款事实或要求技术员工参与;真实录入和付款仍按各自授权执行,不从未还本金推定本次全额支付。 +整体任务保持 `in_progress`。2026-10-10 用户回报财务已完成真实借入录入,截图显示原借入凭据已有附件,但缺少协议/借款确认书和余额核对材料,当前卡在余额确认,公司代表授权尚待办理;本会话未访问生产独立核实。首笔真实还款的网银提交、正式回单、核销及会计交接尚未验收;AC5 及未覆盖的真实材料边界继续开放。无需重新索取已确认的借款事实或要求技术员工参与;真实录入和付款仍按各自授权执行,不从未还本金推定本次全额支付。缺材料的流程问题仅记入 implement,依用户指令暂不修改产品规则。 + +后续安排见 implement 顶部:N5 真实登记为业务优先项,授权翻页的确定性排查可并行,N6 按实际付款计划在 N5 后办理。首笔正常成功链路不一次关闭 AC5 或 AC9–AC19 的全部验收;异常分支及仅适用独立模式的 AC12 保留各自适用条件,不为验收制造真实异常交易。 ### 2026-10-08 N1–N2 完成时点(历史记录) diff --git a/client/src/api/companyLoans.ts b/client/src/api/companyLoans.ts index f6df7a1b..364da6c8 100644 --- a/client/src/api/companyLoans.ts +++ b/client/src/api/companyLoans.ts @@ -16,6 +16,8 @@ export type CompanyLoan = { balanceAsOf: string; historicalEntry: boolean; confirmationStatus: string; version: number; outstanding: string; available: string; reserved: string; settled: string; evidences: LoanEvidence[]; accountingConfirmationRef: string | null; + lenderAcknowledgement?: { id: string; confirmedAt: string; lenderName: string } | null; + lenderAcknowledgementRenewable?: boolean; }; export type LoanRepayment = LoanReconciliation & { approvalMode?: "independent" | "related_party"; authorizedApproverId?: string | null; @@ -101,8 +103,28 @@ export function uploadLoanEvidence(id: string, kind: string, file: File) { const body = new FormData(); body.append("file", file); return apiRequestData(`/api/company-loans/${id}/evidences/${kind}`, { method: "POST", body }); } -export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string, authorizationId?: string) { - return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId, authorizationId }) }); +export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string, authorizationId?: string, + lenderAcknowledgementId?: string, signal?: AbortSignal) { + return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ reason, paymentBatchId, authorizationId, lenderAcknowledgementId }) }); +} + +export type LoanLenderAcknowledgement = { + id: string; lenderId: string; legalEntityId: string; lenderName: string; companyName: string; + proposedByName: string; proposedAt: string; reason: string; contentHash: string; + current: boolean; invalidatedAt: string | null; canDecide: boolean; canReadOriginals: boolean; + status: "pending" | "confirmed" | "returned" | "invalid"; + decision: { action: "confirm" | "return"; reason: string; createdAt: string; lenderName: string } | null; + loans: Array<{ loanId: string; loanNo: string; lenderId: string; legalEntityId: string; originReference: string; + originalPrincipal: string; openingOutstandingPrincipal: string; receivedDate: string; balanceAsOf: string; + originals: Array<{ id: string; sha256: string; originalName: string; fileSize: number; mimeType: string }> }>; +}; +export function fetchLenderAcknowledgements(page: number, signal?: AbortSignal) { + return apiRequestData<{ items: LoanLenderAcknowledgement[]; total: number }>(`/api/loan-lender-acknowledgements?page=${page}&pageSize=30`, { signal }); +} +export function fetchLenderAcknowledgement(id: string, signal?: AbortSignal) { + return apiRequestData(`/api/loan-lender-acknowledgements/${id}`, { signal }); } export type LoanRelatedAuthorization = { diff --git a/client/src/api/errorReasons.ts b/client/src/api/errorReasons.ts index ab383d3f..642457a9 100644 --- a/client/src/api/errorReasons.ts +++ b/client/src/api/errorReasons.ts @@ -29,7 +29,11 @@ export const ERROR_REASON_MESSAGES: Record = { "loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。", "loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。", "loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。", - "loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。", + "loan.lender_ack_scope_invalid": "请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。", + "loan.lender_ack_exists": "所选借款已有待确认或有效的出借人声明,请查看现有记录。", + "loan.lender_ack_changed": "借款内容、原件或出借人资料已变化,请由财务重新发起确认。", + "loan.lender_ack_decided": "这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。", + "loan.evidence_required": "请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。", "loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。", "loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。", "loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。", diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 8b4abad7..9f9bf3c4 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -35,6 +35,7 @@ const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"] as const; const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const; const EMPLOYEE_BASE_PERMISSIONS = [ + "loan.acknowledge_own", ...EXPENSE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS, ...ATTENDANCE_SELF_SERVICE_PERMISSIONS, diff --git a/client/src/components/common/LoanLenderAcknowledgements.tsx b/client/src/components/common/LoanLenderAcknowledgements.tsx new file mode 100644 index 00000000..f152611d --- /dev/null +++ b/client/src/components/common/LoanLenderAcknowledgements.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { useAuth } from "../../auth/AuthContext"; +import { downloadLoanEvidence, fetchCompanyLoans, fetchLoanLenders, fetchLenderAcknowledgement, + fetchLenderAcknowledgements, saveLoanAction, type LoanLenderAcknowledgement } from "../../api/companyLoans"; +import { formatDisplayDate, formatDisplayDateTime } from "../../lib/displayFormat"; +import { resolveErrorMessage } from "../../lib/resolveErrorMessage"; +import { downloadBlob } from "../../lib/download"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog"; + +const statusLabels = { pending: "待出借人确认", confirmed: "出借人已确认", returned: "已退回财务", invalid: "内容或来源已变化,需重新发起" }; +export function LoanLenderAcknowledgements({ finance }: { finance: boolean }) { + const { me } = useAuth(); const cache = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedId = searchParams.get("lenderAcknowledgementId") ?? ""; + const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [loanPage, setLoanPage] = useState(1); + const [proposing, setProposing] = useState(false); + const mode = selectedId ? "details" : proposing ? "propose" : null; + const [lender, setLender] = useState<{ id: string; version: number } | null>(null); + const [selectedLoans, setSelectedLoans] = useState>({}); + const [reason, setReason] = useState(""); const [checked, setChecked] = useState(false); + const [requestKey, setRequestKey] = useState(() => crypto.randomUUID()); + const [downloadError, setDownloadError] = useState(null); + const alive = useRef(true); const actor = useRef(me?.id); actor.current = me?.id; + const selection = useRef(selectedId); selection.current = selectedId; + const requests = useRef(new Set()); + useEffect(() => { alive.current = true; const pending = requests.current; + return () => { alive.current = false; for (const controller of pending) controller.abort(); pending.clear(); }; + }, [me?.id, selectedId]); + const records = useQuery({ queryKey: ["loan-lender-acknowledgements", me?.id, page], + queryFn: ({ signal }) => fetchLenderAcknowledgements(page, signal) }); + const lenders = useQuery({ queryKey: ["loan-lenders", me?.id, "acknowledgement", lenderPage], + queryFn: ({ signal }) => fetchLoanLenders(lenderPage, signal), enabled: finance && mode === "propose", + refetchOnWindowFocus: false, refetchOnReconnect: false }); + const loans = useQuery({ queryKey: ["company-loans", me?.id, "acknowledgement", lender?.id, loanPage], + queryFn: ({ signal }) => fetchCompanyLoans(loanPage, lender?.id, signal), enabled: finance && mode === "propose" && Boolean(lender), + refetchOnWindowFocus: false, refetchOnReconnect: false }); + const detail = useQuery({ queryKey: ["loan-lender-acknowledgements", me?.id, "detail", selectedId], + queryFn: ({ signal }) => fetchLenderAcknowledgement(selectedId, signal), enabled: mode === "details" && Boolean(selectedId), + staleTime: 0, gcTime: 0, refetchOnWindowFocus: false, refetchOnReconnect: false }); + const mutation = useMutation({ mutationFn: async (action: "propose" | "confirm" | "return") => { + const actorId = me?.id; const controller = new AbortController(); requests.current.add(controller); + try { + if (action === "propose") { + if (!lender) return; + await saveLoanAction("/loan-lender-acknowledgements", { lenderId: lender.id, expectedIdentityVersion: lender.version, + loans: Object.entries(selectedLoans).map(([id, version]) => ({ id, version })), requestKey, reason }, "POST", controller.signal); + } else { + if (!detail.data) return; + await saveLoanAction(`/loan-lender-acknowledgements/${detail.data.id}/decision`, { + action, reason, contentHash: detail.data.contentHash, originalChecked: checked, + }, "POST", controller.signal); + } + if (!alive.current || actor.current !== actorId || selection.current !== selectedId) return; + await Promise.all(["loan-lender-acknowledgements", "company-loans"].map((key) => cache.invalidateQueries({ queryKey: [key, actorId] }))); + if (alive.current && actor.current === actorId && selection.current === selectedId) close(); + } finally { requests.current.delete(controller); } + } }); + const resetMutation = mutation.reset; + useEffect(() => { + resetMutation(); setDownloadError(null); setReason(""); setChecked(false); + if (selectedId) { + setProposing(false); setLender(null); setSelectedLoans({}); setLenderPage(1); setLoanPage(1); setRequestKey(crypto.randomUUID()); + } + }, [selectedId, me?.id, resetMutation]); + function selectStatement(id: string) { + setSearchParams((old) => { const params = new URLSearchParams(old); + if (id) params.set("lenderAcknowledgementId", id); else params.delete("lenderAcknowledgementId"); return params; }); + } + function close() { setProposing(false); selectStatement(""); } + function open(next: "propose" | "details", id = "") { + mutation.reset(); setDownloadError(null); setProposing(next === "propose"); selectStatement(id); setLender(null); setSelectedLoans({}); + setReason(""); setChecked(false); setLenderPage(1); setLoanPage(1); setRequestKey(crypto.randomUUID()); + } + async function original(record: LoanLenderAcknowledgement, loanId: string, evidenceId: string, name: string) { + const actorId = me?.id; const controller = new AbortController(); requests.current.add(controller); setDownloadError(null); + try { + const result = await downloadLoanEvidence(loanId, evidenceId, "核对出借人借款声明的原借入凭据", undefined, undefined, record.id, controller.signal); + if (alive.current && actor.current === actorId && selection.current === selectedId) downloadBlob(result.blob, result.filename ?? name); + } catch (error) { if (alive.current && actor.current === actorId && selection.current === selectedId && !controller.signal.aborted) setDownloadError(error); } + finally { requests.current.delete(controller); } + } + const error = mutation.error ?? downloadError ?? (mode === "propose" ? lenders.error ?? loans.error : mode === "details" ? detail.error : records.error); + return
+

出借人确认借款

+

由出借人本人确认借出事实及截至日未收回本金,作为借款确认材料。公司代表授权及代账余额核对分别办理。

+ {finance ? : null} + {!mode && error ?

{resolveErrorMessage(error)}

: null} + {records.isPending ?

加载出借人确认…

: null} + {records.isSuccess && !records.data.items.length ?

暂无可查看的出借人确认。

: null} + {records.data?.items.map((record) =>
+

{record.companyName} · {record.lenderName} · {record.loans.length} 笔借款

+

{statusLabels[record.status]}

+

{record.proposedByName}发起 · {formatDisplayDateTime(record.proposedAt)}

+ +
)} +
+ 第 {page} 页
+ { if (!value && !mutation.isPending) close(); }}> + + {mode === "propose" ? "发起出借人确认" : "出借人借款声明"} + {mode === "propose" ? "选择同一出借人的借款,发送后固定本次明细和原件,交由出借人本人确认。" : "请以出借人身份核对借款和原件。确认后保留本次记录,内容变化需要重新确认。"} + +
{ event.preventDefault(); mutation.mutate(mode === "propose" ? "propose" : "confirm"); }}> + {error ?

{resolveErrorMessage(error)}

: null} + {mode === "propose" ? <> + +
+
+
待确认或需重新确认的借款(已选 {Object.keys(selectedLoans).length} 笔,最多 50 笔) + {loans.isPending && lender ?

加载借款…

: null} + {loans.data?.items.filter((l) => l.confirmationStatus !== "confirmed" || l.lenderAcknowledgementRenewable).map((loan) => { + const available = loan.evidences.some((e) => e.kind === "incoming" && !e.archivedAt && e.governanceStatus === "approved"); + return ; + })} +
+ {lender ?
+
: null} + + + : <> + {detail.isPending ?

加载声明明细…

: null} + {detail.data ? <> +

{detail.data.companyName} · 出借人:{detail.data.lenderName}

{statusLabels[detail.data.status]}

+

财务说明:{detail.data.reason}

+ {detail.data.loans.map((loan) =>
+

{loan.loanNo} · {loan.originReference}

+

借入日期:{formatDisplayDate(loan.receivedDate)} · 原始本金:¥{loan.originalPrincipal}

+

截至 {formatDisplayDate(loan.balanceAsOf)} 未还本金:¥{loan.openingOutstandingPrincipal}

+ {loan.originals.map((e) =>
+ +
)} +
)} + {detail.data.decision ?

{detail.data.decision.lenderName}于 {formatDisplayDateTime(detail.data.decision.createdAt)} + {detail.data.decision.action === "confirm" ? "确认" : "退回"}:{detail.data.decision.reason}

: null} + {detail.data.canDecide ? <> + + +
+
+ : null} + : null} + } +
+ +
+
+
; +} diff --git a/client/src/lib/notificationLinks.ts b/client/src/lib/notificationLinks.ts index 64a582e4..a09b54dd 100644 --- a/client/src/lib/notificationLinks.ts +++ b/client/src/lib/notificationLinks.ts @@ -24,6 +24,10 @@ export function resolveNotificationTargetLink(item: NotificationTarget) { return `/payment-batches?id=${encodeSegment(item.targetId)}`; } + if (item.targetType === "loan_lender_acknowledgement") { + return `/company-loans?lenderAcknowledgementId=${encodeSegment(item.targetId)}`; + } + if (item.targetType === "leave_request") { return `/leave-requests?highlight=${item.targetId}`; } @@ -70,6 +74,10 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) { return `/mobile/payment-batches?id=${encodeSegment(item.targetId)}`; } + if (item.targetType === "loan_lender_acknowledgement") { + return `/mobile/company-loans?lenderAcknowledgementId=${encodeSegment(item.targetId)}`; + } + if (item.targetType === "leave_request") { return `/mobile/leave/${encodeSegment(item.targetId)}`; } diff --git a/client/src/pages/CompanyLoansPage.tsx b/client/src/pages/CompanyLoansPage.tsx index a7280e30..653a48c4 100644 --- a/client/src/pages/CompanyLoansPage.tsx +++ b/client/src/pages/CompanyLoansPage.tsx @@ -1,6 +1,6 @@ import { cloneElement, isValidElement, useEffect, useId, useRef, useState, type FormEvent, type ReactNode } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Link, useLocation, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; import { fetchLegalEntities } from "../api/masterData"; @@ -10,6 +10,7 @@ import { createLoanRepayment, downloadLoanEvidence, fetchCompanyLoans, fetchLoan import { LoanRelatedAuthorizations } from "../components/common/LoanRelatedAuthorizations"; import { LoanEmployeeLenderDialog } from "../components/common/LoanEmployeeLenderDialog"; import { LoanIncomingReceiptDialog } from "../components/common/LoanIncomingReceiptDialog"; +import { LoanLenderAcknowledgements } from "../components/common/LoanLenderAcknowledgements"; import { ListWorkspace, WorkspaceHeader, HeaderActionGroup } from "../components/patterns/ListWorkspace"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; @@ -32,12 +33,19 @@ function Field({ label, children }: { label: string; children: ReactNode }) { export function CompanyLoansPage() { const { me, permissions } = useAuth(); const navigate = useNavigate(); const cache = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); + const acknowledgementId = searchParams.get("lenderAcknowledgementId"); const paymentPath = useLocation().pathname.startsWith("/mobile/") ? "/mobile/payment-batches" : "/payment-batches"; const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const allowed = Boolean(me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role))) && hasPermission(permissions, "loan.manage"); const [page, setPage] = useState(1); const [lenderPage, setLenderPage] = useState(1); const [repayPage, setRepayPage] = useState(1); - const [tab, setTab] = useState<"loans" | "lenders" | "repayments" | "authorizations">("loans"); + const [selectedTab, setSelectedTab] = useState<"loans" | "lenders" | "repayments" | "authorizations" | "acknowledgements">("loans"); + const tab = acknowledgementId ? "acknowledgements" : selectedTab; + function setTab(next: typeof selectedTab) { + setSelectedTab(next); + if (acknowledgementId) setSearchParams((old) => { const params = new URLSearchParams(old); params.delete("lenderAcknowledgementId"); return params; }); + } const [mode, setMode] = useState(null); const [selectedLender, setSelectedLender] = useState(null); const [selectedLoan, setSelectedLoan] = useState(null); const [evidence, setEvidence] = useState<{ loanId: string; id: string; name: string } | null>(null); @@ -95,11 +103,15 @@ export function CompanyLoansPage() { if (mounted.current && identity.current === actorId) setMode(null); }); } - if (!allowed) return me?.roleCodes.some((r) => ["manager", "ceo"].includes(r)) && hasPermission(permissions, "payment.batch.review") - ? + const representsCompany = Boolean(me?.roleCodes.some((r) => ["manager", "ceo"].includes(r))) && hasPermission(permissions, "payment.batch.review"); + if (!allowed) return representsCompany || hasPermission(permissions, "loan.acknowledge_own") + ? + + {representsCompany ? : null} :

当前账号无借款台账权限。

; const error = mutation.error ?? loans.error ?? lenders.error ?? repayments.error ?? companies.error ?? banks.error ?? people.error; const current = tab === "loans" ? loans : tab === "lenders" ? lenders : repayments; + const tableTab = tab === "loans" || tab === "lenders" || tab === "repayments"; const modalQueryBlocked = mode === "lender" ? !companies.isSuccess || !banks.isSuccess : mode === "account" ? !banks.isSuccess : mode === "identity" ? !people.isSuccess : mode === "loan" ? !lenders.isSuccess : mode === "repay" ? !lenders.isSuccess || !candidates.isSuccess : false; const currentPage = tab === "loans" ? page : tab === "lenders" ? lenderPage : repayPage; const setCurrentPage = tab === "loans" ? setPage : tab === "lenders" ? setLenderPage : setRepayPage; @@ -109,13 +121,14 @@ export function CompanyLoansPage() { } />
- {([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) => + {([["loans", "借款台账"], ["lenders", "出借人"], ["repayments", "还款记录"], ["acknowledgements", "出借人确认"], ["authorizations", "公司代表授权"]] as const).map(([key, label]) => )}
{tab === "authorizations" ? : null} + {tab === "acknowledgements" ? : null} {error && !mode ?

{resolveErrorMessage(error)}

: null} - {tab !== "authorizations" && current.isPending ?

加载中…

: null} - {tab !== "authorizations" && current.isSuccess && !current.data.items.length ?

暂无记录;不能据此判断银行实际借还款情况。

: null} + {tableTab && current.isPending ?

加载中…

: null} + {tableTab && current.isSuccess && !current.data.items.length ?

暂无记录;不能据此判断银行实际借还款情况。

: null} {tab === "loans" && loans.isSuccess ? loans.data.items.map((loan) =>

{loan.lender.name} · {loan.loanNo}

{loan.confirmationStatus === "confirmed" ? "余额已确认" : "余额待核实"}
@@ -126,6 +139,8 @@ export function CompanyLoansPage() {

原借入凭据:{loan.originReference}

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

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

+ :

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

}
{Object.entries(evidenceLabels).map(([kind, label]) =>
会计交接:{repayment.accountingHandoffRef ?? "待交接"}

查看还款分配、付款与回单
) : null} - {tab !== "authorizations" ?
+ {tableTab ?
第 {currentPage} 页
: null} {mode === "employeeLender" || mode === "employeeRefresh" ? setMode(null)} onExternal={() => open("lender")} diff --git a/client/src/pages/MobileMePage.tsx b/client/src/pages/MobileMePage.tsx index 8afeae24..2f2a95ac 100644 --- a/client/src/pages/MobileMePage.tsx +++ b/client/src/pages/MobileMePage.tsx @@ -104,7 +104,7 @@ export function MobileMePage() {
- {me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) ? : null} + {hasAnyPermission(permissions, ["loan.manage", "payment.batch.review"]) ? : null} diff --git a/client/src/pages/MobileNotificationsPage.tsx b/client/src/pages/MobileNotificationsPage.tsx index 416d86d6..4cf52f0e 100644 --- a/client/src/pages/MobileNotificationsPage.tsx +++ b/client/src/pages/MobileNotificationsPage.tsx @@ -17,6 +17,7 @@ const PAGE_SIZE = 20; const NOTIFICATION_TYPE_OPTIONS = [ { label: "全部", value: "" }, { label: "审批事件", value: "workflow.task_assigned,workflow.task_reminded,workflow.task_overdue" }, + { label: "借款确认", value: "loan.lender_acknowledgement_requested" }, { label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid,payment_batch.review_requested,payment_batch.execution_requested" }, { label: "请假", value: "leave.approved,leave.rejected" }, { label: "采购", value: "purchase_request.approved,purchase_request.rejected" }, @@ -37,6 +38,7 @@ function formatTime(value: string) { } function notificationTypeLabel(type: string) { + if (type === "loan.lender_acknowledgement_requested") return "借款确认"; if (type.startsWith("workflow.")) return "审批事件"; if (type.startsWith("reimbursement.")) return "报销事件"; if (type.startsWith("payment_batch.")) return "付款事件"; diff --git a/client/src/pages/NotificationsPage.tsx b/client/src/pages/NotificationsPage.tsx index d943c5a1..c10506eb 100644 --- a/client/src/pages/NotificationsPage.tsx +++ b/client/src/pages/NotificationsPage.tsx @@ -15,6 +15,7 @@ const PAGE_SIZE = 20; const NOTIFICATION_TYPE_OPTIONS = [ { label: "全部", value: "" }, { label: "审批提醒", value: "workflow.task_assigned" }, + { label: "借款确认", value: "loan.lender_acknowledgement_requested" }, { label: "报销", value: "reimbursement.approved,reimbursement.rejected,reimbursement.returned,reimbursement.paid,payment_batch.review_requested,payment_batch.execution_requested" }, { label: "请假", value: "leave.approved,leave.rejected" }, { label: "采购", value: "purchase_request.approved,purchase_request.rejected" }, diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 2b986634..21b112ae 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -304,8 +304,8 @@ const routeElements = ( } /> - {withRouteFallback()}} /> - {withRouteFallback()}} /> + {withRouteFallback()}} /> + {withRouteFallback()}} /> route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); const page = await context.newPage(); + const financeReads = []; + page.on('request', request => { + if (['/api/company-loans', '/api/loan-lenders', '/api/loan-repayments'].includes(new URL(request.url()).pathname)) financeReads.push(request.url()); + }); await page.goto(`${origin}/login?redirect=%2Fcompany-loans`); await page.getByTestId('login-identifier').fill(ordinary.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click(); - await expect(page.getByRole('heading', { name: '公司借款', exact: true })).not.toBeVisible(); - await page.waitForURL(/\/403$/); - checks.push('ordinary_employee_cannot_open_loan_ledger'); + await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: '出借人确认借款', exact: true })).toBeVisible(); + await expect(page.getByText('暂无可查看的出借人确认。', { exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0); + await expect(page.getByRole('tablist', { name: '借款台账视图' })).toHaveCount(0); + assert.deepEqual(financeReads, [], 'own declaration entry never queries the finance ledger'); + checks.push('ordinary_employee_only_own_acknowledgements_without_finance_ledger'); await context.close(); console.log(JSON.stringify({ stage: 'loan_browser', status: 'PASS', checks, output: path.relative(root, output) })); } finally { await browser.close(); } diff --git a/scripts/qa/company-loan-http-fixture.cjs b/scripts/qa/company-loan-http-fixture.cjs index 5e2dbf21..f3fcfb44 100644 --- a/scripts/qa/company-loan-http-fixture.cjs +++ b/scripts/qa/company-loan-http-fixture.cjs @@ -421,6 +421,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { // Incoming-receipt scenarios add lenders; in a full run they go last so they // cannot shift the pagination other browser scenarios rely on. const runIncoming = () => require('./company-loan-incoming-fixture.cjs')({ ...employeeHarness, verifiedLender }); + const runLenderAck = () => require('./company-loan-lender-ack-fixture.cjs')({ ...employeeHarness, verifiedLender, admin, system }); + if (process.env.RUN_ISOLATED_LOAN_ACK_ONLY === '1') { + const lenderAck = await runLenderAck(); + if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-lender-ack-browser.cjs')({ ...employeeHarness, lenderAck }); + return; + } if (process.env.RUN_ISOLATED_LOAN_INCOMING_ONLY === '1') { const incomingReceipt = await runIncoming(); if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-incoming-browser.cjs')({ app, finance, otherFinance: await actor(['finance']), password, prisma, config, company, incomingReceipt }); @@ -1402,6 +1408,10 @@ module.exports = async function runLoanHttpFixture(pool, scratch) { await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi, relatedParty, employeeSources }); } await runIncoming(); + // Keep the new lenders last so existing pagination scenarios retain their + // own deterministic data; the acknowledgement UI handles its own pages. + const lenderAck = await runLenderAck(); + if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') await require('./company-loan-lender-ack-browser.cjs')({ ...employeeHarness, lenderAck }); console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks })); } finally { await app.close(); await prisma.$disconnect(); } }; diff --git a/scripts/qa/company-loan-lender-ack-browser.cjs b/scripts/qa/company-loan-lender-ack-browser.cjs new file mode 100644 index 00000000..b1e1822b --- /dev/null +++ b/scripts/qa/company-loan-lender-ack-browser.cjs @@ -0,0 +1,272 @@ +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const { mkdirSync, mkdtempSync } = require('node:fs'); +const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs'); +const root = path.resolve(__dirname, '../..'); +const { chromium, expect } = createRequire(path.join(root, 'client/package.json'))('@playwright/test'); + +module.exports = async function lenderAckBrowser(h) { + const { app, finance, ordinary, password, prisma, config, lenderAck } = h; + const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] }); + const address = app.server.address(); + const origin = address ? `http://127.0.0.1:${address.port}` : await app.listen({ host: '127.0.0.1', port: 0 }); + const oldOrigin = config.appOrigin; config.appOrigin = origin; + const base = path.join(root, 'artifacts/qa-flow/loan-lender-ack-20261010'); mkdirSync(base, { recursive: true }); + const output = mkdtempSync(path.join(base, 'browser-')); const checks = []; + const apiRoot = '/api/loan-lender-acknowledgements'; + try { + for (const s of lenderAck.browserScenarios) { + const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; + const contexts = []; const lenderPages = new Map(); + async function openNotice(page, acknowledgementId) { + const loaded = page.waitForResponse(r => { + const url = new URL(r.url()); + return r.request().method() === 'GET' && r.status() === 200 && url.pathname === '/api/notifications' + && url.searchParams.get('type') === 'loan.lender_acknowledgement_requested' + && url.searchParams.get('page') === '1' && url.searchParams.get('pageSize') === '20' + && url.searchParams.get('unreadOnly') === '0'; + }); + await page.goto(`${origin}${prefix}/notifications?type=loan.lender_acknowledgement_requested`); + const loadedResponse = await loaded; + const items = (await loadedResponse.json()).data.items; + const index = items.findIndex(item => item.targetType === 'loan_lender_acknowledgement' && item.targetId === acknowledgementId); + assert.ok(index >= 0, `the actual lender inbox must include this statement notice: ${JSON.stringify({ + query: new URL(loadedResponse.url()).search, items: items.length, mode: s.mode, + })}`); + if (mobile) { + const cards = page.getByTestId('mobile-notification-card'); await expect(cards).toHaveCount(items.length); + await cards.nth(index).click(); + } else { + const entry = page.getByRole('link', { name: '查看详情', exact: true }).and( + page.locator(`[href="${prefix}/company-loans?lenderAcknowledgementId=${acknowledgementId}"]`)); + await expect(entry).toBeVisible(); await entry.click(); + } + await expect(page.getByRole('dialog')).toBeVisible(); + assert.equal(new URL(page.url()).searchParams.get('lenderAcknowledgementId'), acknowledgementId); + } + async function login(who, acknowledgementId) { + const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, + serviceWorkers: 'block', timezoneId: 'UTC' }); contexts.push(context); + await context.route('**/*', (route) => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort()); + const page = await context.newPage(); + if (who.id === finance.id) page.on('response', async response => { + const url = new URL(response.url()); + if (url.pathname === '/api/loan-lenders' && response.status() === 200) { + const body = await response.json().catch(() => null); + if (body?.data?.items) lenderPages.set(Number(url.searchParams.get('page') ?? 1), body.data.items); + } + }); + const viaNotice = who.id === s.signer.id; + const target = viaNotice ? mobile ? '/mobile/me' : '/portal' : `${prefix}/company-loans`; + await page.goto(`${origin}/login?redirect=${encodeURIComponent(target)}`); + await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); + await page.getByTestId('login-submit').click(); + if (viaNotice) { + await expect(page.getByTestId('login-submit')).toHaveCount(0); + if (mobile) await expect(page.getByRole('button', { name: '退出登录', exact: true })).toBeVisible(); + else await expect(page.locator('nav').first()).toBeVisible(); + await expect(page.getByRole('link', { name: '公司借款', exact: true })).toHaveCount(0); + await openNotice(page, acknowledgementId); + await expect(page.getByRole('heading', { name: '出借人借款声明', exact: true })).toBeVisible(); + checks.push(`${s.mode}_own_lender_has_no_permanent_navigation_and_enters_statement_from_notice`); + } + else await expect(page.getByRole('heading', { name: '公司借款', exact: true })).toBeVisible(); + return page; + } + let fp; let lp; + try { + fp = await login(finance); + await fp.getByRole('tab', { name: '出借人确认', exact: true }).click(); + async function openProposalUi(selected) { + await fp.getByRole('button', { name: '发起出借人确认', exact: true }).click(); + const dialog = fp.getByRole('dialog'); const select = dialog.getByLabel('声明出借人'); + await expect(select).toBeVisible(); + for (let pageNumber = 1; pageNumber <= 20; pageNumber++) { + await expect.poll(() => lenderPages.has(pageNumber)).toBe(true); + const expected = ['', ...lenderPages.get(pageNumber) + .filter(l => l.employeeId && l.employeeId !== finance.id && l.identityStatus === 'verified').map(l => l.id)]; + await expect.poll(() => select.locator('option').evaluateAll(options => options.map(o => o.value))).toEqual(expected); + if (await select.locator(`option[value="${s.lender.id}"]`).count()) break; + const next = dialog.getByRole('button', { name: '下一页出借人', exact: true }); await expect(next).toBeEnabled(); + await next.click(); + } + await select.selectOption(s.lender.id); + for (const loan of selected) await dialog.getByRole('checkbox', { name: new RegExp(loan.loanNo) }).check(); + return dialog; + } + async function proposeUi(selected) { + const dialog = await openProposalUi(selected); + await dialog.getByLabel('发起说明', { exact: true }).fill('合成界面借款事实及余额核对'); + const response = fp.waitForResponse(r => new URL(r.url()).pathname === apiRoot && r.request().method() === 'POST'); + await dialog.getByRole('button', { name: '发送给出借人', exact: true }).click(); + const result = await response; assert.equal(result.status(), 200); await expect(dialog).not.toBeVisible(); + return (await result.json()).data; + } + const record = await proposeUi(s.loans.slice(0, 2)); + const returned = await proposeUi([s.loans[2]]); + const financeCard = fp.locator('section.card-std').filter({ hasText: s.lender.name }).filter({ hasText: '2 笔借款' }).first(); + await financeCard.getByRole('button', { name: '查看借款确认记录' }).click(); + await expect(fp.getByRole('dialog').getByRole('button', { name: '确认借款声明', exact: true })).toHaveCount(0); + await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); + checks.push(`${s.mode}_finance_proposes_multiple_loans_but_cannot_sign`); + + // A URL detail must override a draft in the same document; switching A + // to B and browser-back to A must query/render the corresponding record. + await fp.evaluate(() => { window.__syntheticAckHistoryDocument = 'same-document'; }); + const draft = await openProposalUi([s.loans[2]]); + await expect(draft.getByLabel('声明出借人')).toHaveValue(s.lender.id); + await expect(draft.locator('legend')).toContainText('已选 1 笔'); + await draft.getByLabel('发起说明', { exact: true }).fill('合成未发送草稿'); + async function selectStatementInSpa(id) { + await fp.evaluate((id) => { + const url = new URL(window.location.href); url.searchParams.set('lenderAcknowledgementId', id); + window.history.pushState({}, '', url); window.dispatchEvent(new PopStateEvent('popstate')); + }, id); + } + async function expectStatement(id, included, excluded) { + assert.equal(new URL(fp.url()).searchParams.get('lenderAcknowledgementId'), id); + const details = fp.getByRole('dialog'); + await expect(details.getByRole('heading', { name: '出借人借款声明', exact: true })).toBeVisible(); + for (const loan of included) await expect(details).toContainText(loan.loanNo); + for (const loan of excluded) await expect(details).not.toContainText(loan.loanNo); + await expect(details.getByLabel('发起说明', { exact: true })).toHaveCount(0); + } + await selectStatementInSpa(record.id); + await expectStatement(record.id, s.loans.slice(0, 2), [s.loans[2]]); + await selectStatementInSpa(returned.id); + await expectStatement(returned.id, [s.loans[2]], s.loans.slice(0, 2)); + await fp.goBack(); + await expectStatement(record.id, s.loans.slice(0, 2), [s.loans[2]]); + await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); + await fp.getByRole('button', { name: '发起出借人确认', exact: true }).click(); + await expect(fp.getByRole('dialog').getByLabel('发起说明', { exact: true })).toHaveValue(''); + await expect(fp.getByRole('dialog').getByLabel('声明出借人')).toHaveValue(''); + await expect(fp.getByRole('dialog').locator('legend')).toContainText('已选 0 笔'); + assert.equal(await fp.evaluate(() => window.__syntheticAckHistoryDocument), 'same-document'); + await fp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); + checks.push(`${s.mode}_statement_url_overrides_draft_switches_records_and_browser_back_without_stale_draft`); + + lp = await login(s.signer, record.id); + await expect(lp.getByRole('button', { name: '登记借入', exact: true })).toHaveCount(0); + await expect(lp.getByRole('button', { name: '发起出借人确认', exact: true })).toHaveCount(0); + let dialog = lp.getByRole('dialog'); + for (const loan of s.loans.slice(0, 2)) await expect(dialog).toContainText(loan.loanNo); + await expect(dialog).toContainText('2025年3月1日'); await expect(dialog).toContainText('2026年1月1日'); + assert.ok(await dialog.evaluate(el => el.scrollWidth - el.clientWidth) <= 1, 'statement dialog has no horizontal overflow'); + await lp.screenshot({ path: path.join(output, `${s.mode}-statement.png`), fullPage: true }); + const [downloadResponse, download] = await Promise.all([ + lp.waitForResponse(r => r.url().endsWith('/download') && r.request().method() === 'POST'), + lp.waitForEvent('download'), dialog.getByRole('button', { name: '查看原借入凭据:synthetic.pdf', exact: true }).first().click(), + ]); + assert.equal(downloadResponse.status(), 200); assert.match(downloadResponse.request().headers()['content-type'], /application\/json/); + assert.equal(await download.failure(), null); + const confirm = dialog.getByRole('button', { name: '确认借款声明', exact: true }); await expect(confirm).toBeDisabled(); + await dialog.getByRole('checkbox').check(); await dialog.getByLabel('确认或退回说明').fill('合成本人核对原件及金额无误'); + // Same identity retains one normal token refresh, without duplicate decisions. + let attempts = 0; + const expireOnce = route => ++attempts === 1 ? route.fulfill({ status: 401, contentType: 'application/json', + body: JSON.stringify({ requestId: 'synthetic-expired', error: { code: 'AUTH_001', message: '合成过期' } }) }) : route.continue(); + await lp.route(`**${apiRoot}/${record.id}/decision`, expireOnce); + try { + await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${record.id}/decision`) && r.status() === 200), confirm.click()]); + await expect(dialog).not.toBeVisible(); assert.equal(attempts, 2); + } finally { await lp.unroute(`**${apiRoot}/${record.id}/decision`, expireOnce); } + assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: record.id } }), 1); + await expect(lp.getByText('出借人已确认', { exact: true })).toBeVisible(); + assert.ok(await lp.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) <= 1); + await lp.screenshot({ path: path.join(output, `${s.mode}-confirmed.png`), fullPage: true }); + checks.push(`${s.mode}_own_lender_downloads_original_checks_statement_and_confirms_with_same_actor_refresh`); + + // Finance still needs accounting evidence; actual UI upload then confirms balance. + await fp.getByRole('tab', { name: '借款台账', exact: true }).click(); + await fp.reload(); + const loan = s.loans[0]; + const card = fp.locator('section.card-std').filter({ has: fp.getByRole('heading', { name: `${s.lender.name} · ${loan.loanNo}`, exact: true }) }); + for (let i = 0; i < 30 && !await card.count(); i++) { + const first = fp.locator('section.card-std').first(); await expect(first).toBeVisible(); + if (await card.count()) break; + const oldText = await first.textContent(); const next = fp.getByRole('button', { name: '下一页', exact: true }); + await expect(next).toBeEnabled(); + await Promise.all([fp.waitForResponse(r => new URL(r.url()).pathname === '/api/company-loans'), next.click()]); + await expect(first).not.toHaveText(oldText); + } + await card.locator('summary').click(); await expect(card).toContainText('已有有效的出借人在线确认'); + await card.getByRole('button', { name: '确认期初余额', exact: true }).click(); + dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间'); + const denied = fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`)); + await dialog.getByRole('button', { name: '确认', exact: true }).click(); assert.equal((await denied).status(), 409); + await expect(dialog.getByRole('alert')).toBeVisible(); await dialog.press('Escape'); await expect(dialog).not.toBeVisible(); + const uploaded = fp.waitForResponse(r => r.url().endsWith('/evidences/accounting_confirmation')); + await card.getByLabel('余额核对材料', { exact: true }).setInputFiles({ name: 'synthetic-accounting.pdf', mimeType: 'application/pdf', + buffer: await syntheticBankReceiptPdf({ reference: `SYN-ACCOUNTING-${s.mode}`, amount: '100.00' }) }); + assert.equal((await uploaded).status(), 200); + await card.getByRole('button', { name: '确认期初余额', exact: true }).click(); + dialog = fp.getByRole('dialog'); await dialog.getByLabel('代账余额确认引用').fill('合成代账明细科目和期间'); + await Promise.all([fp.waitForResponse(r => r.url().endsWith(`/${loan.id}/confirm`) && r.status() === 200), + dialog.getByRole('button', { name: '确认', exact: true }).click()]); + await expect(dialog).not.toBeVisible(); await expect(card).toContainText('余额已确认'); + checks.push(`${s.mode}_accounting_required_ui_upload_then_balance_confirmation`); + + const beforeRenewal = await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } }); + await lenderAck.upload(loan); + await openNotice(lp, record.id); + await expect(lp.getByRole('dialog')).toContainText('内容或来源已变化,需重新发起'); + await expect(lp.getByRole('dialog').getByRole('button', { name: '确认借款声明', exact: true })).toHaveCount(0); + await lp.getByRole('dialog').getByRole('button', { name: '关闭', exact: true }).click(); + checks.push(`${s.mode}_historical_notice_opens_invalid_statement_without_reactivating_confirmation`); + await fp.getByRole('tab', { name: '出借人确认', exact: true }).click(); + const renewed = await proposeUi([loan]); + await openNotice(lp, renewed.id); + dialog = lp.getByRole('dialog'); await dialog.getByRole('checkbox').check(); + await dialog.getByLabel('确认或退回说明').fill('合成本人重新核对新原件及原截至日余额'); + await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${renewed.id}/decision`) && r.status() === 200), + dialog.getByRole('button', { name: '确认借款声明', exact: true }).click()]); + await expect(dialog).not.toBeVisible(); + assert.deepEqual(await prisma.companyLoan.findUniqueOrThrow({ where: { id: loan.id } }), beforeRenewal); + checks.push(`${s.mode}_invalid_statement_can_be_renewed_after_balance_confirmation_without_changing_balance`); + + await fp.getByRole('tab', { name: '出借人确认', exact: true }).click(); + await lp.reload(); await lp.getByRole('button', { name: '核对并处理借款声明', exact: true }).click(); + dialog = lp.getByRole('dialog'); await dialog.getByLabel('确认或退回说明').fill('合成借款金额待财务重新核对'); + await Promise.all([lp.waitForResponse(r => r.url().endsWith(`/${returned.id}/decision`) && r.status() === 200), + dialog.getByRole('button', { name: '退回财务', exact: true }).click()]); + await expect(dialog).not.toBeVisible(); await expect(lp.getByText('已退回财务', { exact: true })).toBeVisible(); + const reissued = await proposeUi([s.loans[2]]); assert.notEqual(reissued.id, returned.id); + checks.push(`${s.mode}_lender_returns_and_finance_reissues_immutable_request`); + + // Hold a decision, switch to another real account in the same SPA, deliver + // a late 401: the old mutation is aborted and never replayed as new actor. + await lp.reload(); await lp.getByRole('button', { name: '核对并处理借款声明', exact: true }).click(); + dialog = lp.getByRole('dialog'); await dialog.getByRole('checkbox').check(); + await dialog.getByLabel('确认或退回说明').fill('合成账号切换中的旧确认'); + let release; const gate = new Promise(resolve => { release = resolve; }); let attemptsSwitch = 0; let firstRequest; let aborted = false; + let started; const holding = new Promise(resolve => { started = resolve; }); + const onFailed = request => { if (request === firstRequest) aborted = /aborted/i.test(request.failure()?.errorText ?? ''); }; + const handler = async route => { attemptsSwitch++; firstRequest = route.request(); started(); await gate; + await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ requestId: 'synthetic-late', + error: { code: 'AUTH_001', message: '合成迟到响应' } }) }).catch(() => {}); }; + lp.on('requestfailed', onFailed); await lp.route(`**${apiRoot}/${reissued.id}/decision`, handler); + try { + await lp.evaluate(() => { window.__syntheticAckDocument = 'same-document'; }); + await dialog.getByRole('button', { name: '确认借款声明', exact: true }).click(); await holding; + await lp.evaluate(() => { window.history.pushState({}, '', '/mobile/me'); window.dispatchEvent(new PopStateEvent('popstate')); }); + await lp.getByRole('button', { name: '退出登录', exact: true }).click(); + await expect(lp.getByTestId('login-submit')).toBeVisible(); + await lp.getByTestId('login-identifier').fill(ordinary.email); await lp.getByTestId('login-password').fill(password); + await lp.getByTestId('login-submit').click(); await expect(lp.getByTestId('login-submit')).toHaveCount(0); + release(); await expect.poll(() => aborted).toBe(true); + assert.equal(await lp.evaluate(() => window.__syntheticAckDocument), 'same-document'); assert.equal(attemptsSwitch, 1); + assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: reissued.id } }), 0); + } finally { release(); lp.off('requestfailed', onFailed); await lp.unroute(`**${apiRoot}/${reissued.id}/decision`, handler); } + checks.push(`${s.mode}_late_401_decision_aborted_across_real_actor_switch_without_replay`); + } catch (error) { + for (const [name, page] of [['finance', fp], ['lender', lp]]) if (page) + await page.screenshot({ path: path.join(output, `${s.mode}-${name}-failure.png`), fullPage: true, timeout: 5000 }).catch(() => {}); + throw error; + } finally { for (const context of contexts) await context.close(); } + } + } finally { config.appOrigin = oldOrigin; await browser.close(); } + console.log(JSON.stringify({ stage: 'loan_lender_ack_browser', status: 'PASS', checks, output: path.relative(root, output) })); + return checks; +}; diff --git a/scripts/qa/company-loan-lender-ack-fixture.cjs b/scripts/qa/company-loan-lender-ack-fixture.cjs new file mode 100644 index 00000000..932ba26f --- /dev/null +++ b/scripts/qa/company-loan-lender-ack-fixture.cjs @@ -0,0 +1,289 @@ +const assert = require('node:assert/strict'); +const { randomUUID } = require('node:crypto'); +const { setTimeout: delay } = require('node:timers/promises'); +const { syntheticBankReceiptPdf } = require('./synthetic-bank-receipt.cjs'); +const { NOTIFICATION_TYPES, IM_SUPPORTED_NOTIFICATION_TYPES } = require('../../server/src/constants/notificationTypes'); + +module.exports = async function lenderAckFixture(h) { + const { prisma, pool, app, call, finance, manager, ordinary, actor, verifiedLender, repay } = h; + const checks = []; + const root = '/loan-lender-acknowledgements'; + async function upload(loan, kind = 'incoming') { + const boundary = `synthetic-${randomUUID()}`; + const buffer = await syntheticBankReceiptPdf({ reference: `ACK-${randomUUID()}`, amount: '100.00', direction: 'credit' }); + const res = await app.inject({ method: 'POST', url: `/api/company-loans/${loan.id}/evidences/${kind}`, + headers: { ...finance.headers, 'content-type': `multipart/form-data; boundary=${boundary}` }, + payload: Buffer.concat([Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="synthetic.pdf"\r\nContent-Type: application/pdf\r\n\r\n`), buffer, + Buffer.from(`\r\n--${boundary}--\r\n`)]) }); + assert.equal(res.statusCode, 200, `upload ${kind}`); return { evidence: res.json().data, buffer }; + } + async function loan(lender, extra = {}, withOriginal = true) { + const value = await call(finance, 'POST', '/company-loans', { requestKey: randomUUID(), lenderId: lender.id, + originReference: `SYN-ACK-${randomUUID()}`, originalPrincipal: '100.00', openingOutstandingPrincipal: '100.00', + receivedDate: '2025-03-01', balanceAsOf: '2026-01-01', historicalEntry: true, ...extra }); + if (withOriginal) value.original = (await upload(value)).evidence; + return value; + } + async function setup(role = 'employee', count = 1) { + const signer = await actor([role]); + const lender = await verifiedLender(`合成声明出借人-${randomUUID().slice(0, 6)}`, signer.id, '4'); + const loans = []; for (let i = 0; i < count; i++) loans.push(await loan(lender)); + return { signer, lender, loans }; + } + const body = (s, selected = s.loans) => ({ lenderId: s.lender.id, expectedIdentityVersion: s.lender.identityVersion, + loans: selected.map((l) => ({ id: l.id, version: l.version })), requestKey: randomUUID(), reason: '合成请本人核对借款事实和未还本金' }); + const propose = (s) => call(finance, 'POST', root, body(s)); + const decisionBody = (record, action = 'confirm') => ({ action, contentHash: record.contentHash, + reason: action === 'confirm' ? '合成本人已核对并确认' : '合成金额有误退回核对', originalChecked: action === 'confirm' }); + const decide = (s, record, action = 'confirm', status = 200, reason) => call(s.signer, 'POST', `${root}/${record.id}/decision`, decisionBody(record, action), status, reason); + const confirmBalance = (l, status = 200) => call(finance, 'POST', `/company-loans/${l.id}/confirm`, { + expectedVersion: l.version, accountingConfirmationRef: '合成代账明细账科目及期间引用', + }, status, status === 409 ? 'loan.evidence_required' : undefined); + + const s = await setup('manager', 2); const input = body(s); + const a = await call(finance, 'POST', root, input); + assert.equal(a.status, 'pending'); assert.equal(a.loans.length, 2); assert.equal(a.canDecide, false); + assert.equal((await call(finance, 'POST', root, input)).id, a.id); + await call(finance, 'POST', root, { ...input, reason: '合成换参数重用请求键' }, 409, 'loan.duplicate_registration'); + await call(finance, 'POST', root, body(s), 409, 'loan.lender_ack_exists'); + const noticeType = NOTIFICATION_TYPES.loanLenderAcknowledgementRequested; + const notices = await prisma.notification.findMany({ where: { target_type: 'loan_lender_acknowledgement', target_id: a.id } }); + assert.equal(notices.length, 1); assert.equal(notices[0].recipient_employee_id, s.signer.id); assert.equal(notices[0].type, noticeType); + assert.ok(!IM_SUPPORTED_NOTIFICATION_TYPES.includes(noticeType), 'lender statement notice stays in OA'); + assert.equal(await prisma.notificationDelivery.count({ where: { notification_id: notices[0].id } }), 0); + const inbox = await call(s.signer, 'GET', `/notifications?type=${noticeType}`); + assert.equal(inbox.items.filter(item => item.targetId === a.id).length, 1); + for (const who of [ordinary, finance, manager]) { + assert.equal((await call(who, 'GET', `/notifications?type=${noticeType}`)).items.filter(item => item.targetId === a.id).length, 0); + } + const noticeCopy = notices[0].title + notices[0].body; + for (const privateValue of [s.lender.bankAccount, s.lender.name, '100.00']) assert.ok(!noticeCopy.includes(privateValue)); + checks.push('proposal_notice_only_for_actual_lender_no_duplicate_on_retry_or_external_delivery'); + assert.ok(!JSON.stringify(a).includes(s.lender.bankAccount)); + for (const item of a.loans) { assert.equal(item.originalPrincipal, '100.00'); assert.match(item.originals[0].sha256, /^[a-f0-9]{64}$/); } + checks.push('same_lender_multi_loan_snapshot_request_binding_and_duplicate_refusal'); + + await call(null, 'GET', root, undefined, 401); + await call(ordinary, 'POST', root, input, 403); + assert.equal((await call(ordinary, 'GET', root)).items.length, 0); + for (const who of [ordinary, manager]) { + await call(who, 'GET', `${root}/${a.id}`, undefined, 404); + await call(who, 'POST', `${root}/${a.id}/decision`, decisionBody(a), 404); + } + await call(finance, 'POST', `${root}/${a.id}/decision`, decisionBody(a), 403); + await call(s.signer, 'GET', '/company-loans', undefined, 403); + await call(s.signer, 'POST', `${root}/${a.id}/decision`, { ...decisionBody(a), originalChecked: false }, 400); + await call(s.signer, 'POST', `${root}/${a.id}/decision`, { ...decisionBody(a), contentHash: '0'.repeat(64) }, 409, 'loan.lender_ack_changed'); + const download = (who, l, evidence, extra) => app.inject({ method: 'POST', url: `/api/company-loans/${l.id}/evidences/${evidence.id}/download`, + headers: who.headers, payload: { reason: '合成核对本人原借入凭据', lenderAcknowledgementId: a.id, ...extra } }); + assert.equal((await download(s.signer, s.loans[0], s.loans[0].original)).statusCode, 200); + assert.equal((await download(ordinary, s.loans[0], s.loans[0].original)).statusCode, 404); + const accounting = await upload(s.loans[0], 'accounting_confirmation'); + assert.equal((await download(s.signer, s.loans[0], accounting.evidence)).statusCode, 404); + assert.equal((await download(s.signer, s.loans[0], s.loans[0].original, { paymentBatchId: randomUUID() })).statusCode, 400); + checks.push('only_actual_lender_can_read_originals_and_decide_no_finance_or_representative_substitution'); + + await confirmBalance(s.loans[0], 409); // Accounting alone cannot replace agreement/ack. + await decide(s, a); await decide(s, a); // Same decision retry is idempotent. + await decide(s, a, 'return', 409, 'loan.lender_ack_decided'); + await confirmBalance(s.loans[1], 409); // Valid ack cannot replace accounting. + await upload(s.loans[1], 'accounting_confirmation'); + for (const l of s.loans) { + const value = await confirmBalance(l); assert.equal(value.confirmationStatus, 'confirmed'); + assert.equal(value.lenderAcknowledgement.id, a.id); + } + assert.equal((await call(s.signer, 'GET', `${root}/${a.id}`)).status, 'confirmed'); + const authority = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: s.lender.id, + loanIds: s.loans.map((l) => l.id), basis: '合成已有公司代表权及指定借款范围依据', requestKey: randomUUID() }); + await call(s.signer, 'POST', `/loan-related-authorizations/${authority.id}/actions`, { action: 'confirm', reason: '合成以公司代表身份单独确认授权' }); + const batch = await repay(s.lender.id, s.loans[0].id, '10.00'); + assert.equal(batch.loanRepayment.approvalMode, 'related_party'); + await call(s.signer, 'POST', `/payment-batches/${batch.id}/review`, {}); + await h.exportOrder(batch.id); + checks.push('online_ack_replaces_only_agreement_and_reaches_separate_authority_repayment_review_and_export'); + + const ordinaryCase = await setup(); const ordinaryAck = await propose(ordinaryCase); + assert.equal((await call(ordinaryCase.signer, 'GET', `${root}/${ordinaryAck.id}`)).canDecide, true); + await decide(ordinaryCase, ordinaryAck, 'return'); + assert.equal((await call(finance, 'GET', `${root}/${ordinaryAck.id}`)).status, 'returned'); + const resent = await propose(ordinaryCase); await decide(ordinaryCase, resent); + assert.notEqual(resent.id, ordinaryAck.id); + checks.push('ordinary_employee_lender_can_return_and_confirm_new_request_without_manager_permission'); + + const partial = await loan(ordinaryCase.lender, { openingOutstandingPrincipal: '80.00' }); + const partialScope = { ...ordinaryCase, loans: [partial] }; const partialAck = await propose(partialScope); await decide(partialScope, partialAck); + await upload(partial, 'accounting_confirmation'); await confirmBalance(partial, 409); + await upload(partial, 'adjustment'); await confirmBalance(partial); + const paper = await loan(ordinaryCase.lender); await upload(paper, 'agreement'); await upload(paper, 'accounting_confirmation'); await confirmBalance(paper); + checks.push('outside_repayment_evidence_still_required_and_paper_agreement_remains_valid'); + + const missing = await loan(ordinaryCase.lender, {}, false); + await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing] }), 409, 'loan.lender_ack_scope_invalid'); + await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing, s.loans[0]] }), 409, 'loan.lender_ack_scope_invalid'); + await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [missing, missing] }), 409, 'loan.lender_ack_scope_invalid'); + const ownLender = await verifiedLender('合成财务本人出借人', finance.id, '2'); + const ownLoan = await loan(ownLender); + await call(finance, 'POST', root, body({ lender: ownLender, loans: [ownLoan] }), 409, 'loan.lender_ack_scope_invalid'); + checks.push('scope_requires_distinct_maker_verified_lender_same_company_unconfirmed_loans_and_originals'); + + await assert.rejects(prisma.loanLenderAcknowledgement.update({ where: { id: a.id }, data: { reason: '合成篡改声明' } })); + await assert.rejects(prisma.loanLenderAcknowledgementLoan.update({ where: { acknowledgement_id_loan_id: { acknowledgement_id: a.id, loan_id: s.loans[0].id } }, data: { snapshot_json: {} } })); + await assert.rejects(prisma.loanLenderAcknowledgementDecision.update({ where: { acknowledgement_id: a.id }, data: { reason: '合成篡改决定' } })); + await assert.rejects(prisma.loanLenderAcknowledgementDecision.delete({ where: { acknowledgement_id: a.id } })); + await assert.rejects(prisma.loanLenderAcknowledgementLoan.deleteMany({ where: { acknowledgement_id: a.id } })); + checks.push('database_rejects_snapshot_decision_and_scope_history_rewrites_or_deletion'); + + for (const [column, changed] of [['original_principal', '110.00'], ['opening_outstanding_principal', '90.00'], ['received_date', '2025-03-02'], ['balance_as_of', '2026-01-02']]) { + const l = await loan(ordinaryCase.lender); const itemScope = { ...ordinaryCase, loans: [l] }; const record = await propose(itemScope); await decide(itemScope, record); + const before = (await pool.query(`SELECT ${column}::text AS value FROM company_loans WHERE id=$1`, [l.id])).rows[0].value; + await pool.query(`UPDATE company_loans SET ${column}=$1 WHERE id=$2`, [changed, l.id]); + await pool.query(`UPDATE company_loans SET ${column}=$1 WHERE id=$2`, [before, l.id]); + assert.equal((await call(ordinaryCase.signer, 'GET', `${root}/${record.id}`)).status, 'invalid'); + await upload(l, 'accounting_confirmation'); await confirmBalance(l, 409); + await assert.rejects(prisma.loanLenderAcknowledgement.update({ where: { id: record.id }, data: { invalidated_at: null, invalid_reason: null } })); + } + checks.push('each_amount_and_date_change_permanently_invalidates_even_after_restoring_old_values'); + + const changedOriginal = await setup('employee', 2); const originalAck = await propose(changedOriginal); await decide(changedOriginal, originalAck); + const addition = await upload(changedOriginal.loans[0]); + await prisma.loanEvidence.update({ where: { id: addition.evidence.id }, data: { archived_at: new Date() } }); + const oldRead = await call(changedOriginal.signer, 'GET', `${root}/${originalAck.id}`); + assert.equal(oldRead.status, 'invalid'); assert.equal(oldRead.canReadOriginals, false); + for (const l of changedOriginal.loans) { await upload(l, 'accounting_confirmation'); await confirmBalance(l, 409); } + const identityCase = await setup(); const identityAck = await propose(identityCase); await decide(identityCase, identityAck); + await prisma.loanLender.update({ where: { id: identityCase.lender.id }, data: { identity_version: { increment: 1 } } }); + await prisma.loanLender.update({ where: { id: identityCase.lender.id }, data: { identity_version: identityCase.lender.identityVersion } }); + assert.equal((await call(identityCase.signer, 'GET', `${root}/${identityAck.id}`)).status, 'invalid'); + checks.push('original_or_identity_changes_invalidate_whole_group_and_cannot_reactivate'); + + const rollbackCase = await setup(); const rollbackBody = body(rollbackCase); + async function auditFailure(action, run) { + await pool.query(`CREATE FUNCTION synthetic_fail_lender_ack_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action='${action}' THEN RAISE EXCEPTION 'synthetic audit failure'; END IF; RETURN NEW; END $$`); + await pool.query('CREATE TRIGGER synthetic_fail_lender_ack_audit BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_fail_lender_ack_audit()'); + try { await run(); } finally { await pool.query('DROP TRIGGER synthetic_fail_lender_ack_audit ON audit_logs'); await pool.query('DROP FUNCTION synthetic_fail_lender_ack_audit()'); } + } + await auditFailure('LOAN_LENDER_ACK_PROPOSED', () => call(finance, 'POST', root, rollbackBody, 500)); + assert.equal(await prisma.loanLenderAcknowledgement.count({ where: { request_key: rollbackBody.requestKey } }), 0); + assert.equal(await prisma.notification.count({ where: { recipient_employee_id: rollbackCase.signer.id, type: noticeType } }), 0); + const rollbackAck = await call(finance, 'POST', root, rollbackBody); + await auditFailure('LOAN_LENDER_ACK_CONFIRMED', () => decide(rollbackCase, rollbackAck, 'confirm', 500)); + assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: rollbackAck.id } }), 0); + checks.push('proposal_and_decision_audit_failures_roll_back_completely'); + + const notificationFailure = await setup(); const notificationBody = body(notificationFailure); + const auditsBefore = await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_ACK_PROPOSED' } }); + await pool.query(`CREATE FUNCTION synthetic_fail_ack_notice() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.type='${noticeType}' THEN RAISE EXCEPTION 'synthetic notification failure'; END IF; RETURN NEW; END $$`); + await pool.query('CREATE TRIGGER synthetic_fail_ack_notice BEFORE INSERT ON notifications FOR EACH ROW EXECUTE FUNCTION synthetic_fail_ack_notice()'); + try { await call(finance, 'POST', root, notificationBody, 500); } + finally { await pool.query('DROP TRIGGER synthetic_fail_ack_notice ON notifications'); await pool.query('DROP FUNCTION synthetic_fail_ack_notice()'); } + assert.equal(await prisma.loanLenderAcknowledgement.count({ where: { request_key: notificationBody.requestKey } }), 0); + assert.equal(await prisma.auditLog.count({ where: { action: 'LOAN_LENDER_ACK_PROPOSED' } }), auditsBefore); + assert.equal(await prisma.notification.count({ where: { recipient_employee_id: notificationFailure.signer.id, type: noticeType } }), 0); + const afterNotificationFailure = await call(finance, 'POST', root, notificationBody); + assert.equal(await prisma.notification.count({ where: { target_id: afterNotificationFailure.id, type: noticeType } }), 1); + checks.push('notification_write_failure_rolls_back_statement_and_success_audit_then_same_request_can_retry'); + + const racing = await setup(); const raceInput = body(racing); + const pair = await Promise.all([call(finance, 'POST', root, raceInput), call(finance, 'POST', root, raceInput)]); + assert.equal(pair[0].id, pair[1].id); + assert.equal(await prisma.notification.count({ where: { target_id: pair[0].id, type: noticeType } }), 1); + const responses = await Promise.all(['confirm', 'return'].map((action) => app.inject({ method: 'POST', + url: `/api${root}/${pair[0].id}/decision`, headers: racing.signer.headers, payload: decisionBody(pair[0], action) }))); + assert.deepEqual(responses.map((r) => r.statusCode).sort(), [200, 409]); + assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: pair[0].id } }), 1); + checks.push('concurrent_duplicate_proposal_and_conflicting_decisions_have_one_result'); + + const sourceRace = await setup(); const sourceAck = await propose(sourceRace); + const locker = await pool.connect(); + try { + await locker.query('BEGIN'); await locker.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [sourceRace.lender.id]); + const pending = decide(sourceRace, sourceAck, 'confirm', 409, 'loan.lender_ack_changed'); + let blocked = false; + for (let i = 0; i < 80; i++) { const state = await pool.query("SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%loan_lenders%' LIMIT 1"); + if (state.rowCount) { blocked = true; break; } await delay(20); } + assert.ok(blocked, 'confirmation must wait for the source lock'); + await locker.query('UPDATE company_loans SET opening_outstanding_principal=90 WHERE id=$1', [sourceRace.loans[0].id]); + await locker.query('COMMIT'); await pending; + assert.equal(await prisma.loanLenderAcknowledgementDecision.count({ where: { acknowledgement_id: sourceAck.id } }), 0); + } finally { await locker.query('ROLLBACK'); locker.release(); } + checks.push('observed_source_lock_interleaving_rejects_stale_confirmation'); + + for (const change of ['identity', 'sibling_original']) { + const balanceRace = await setup('employee', 2); const balanceAck = await propose(balanceRace); + await decide(balanceRace, balanceAck); await upload(balanceRace.loans[0], 'accounting_confirmation'); + const sourceWriter = await pool.connect(); let pending; + try { + await sourceWriter.query('BEGIN'); + await sourceWriter.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [balanceRace.lender.id]); + pending = app.inject({ method: 'POST', url: `/api/company-loans/${balanceRace.loans[0].id}/confirm`, + headers: finance.headers, payload: { expectedVersion: balanceRace.loans[0].version, accountingConfirmationRef: '合成并发代账引用' } }); + // Observe an actual waiter rather than relying on a scheduled delay. + let blocked = false; + for (let i = 0; i < 80; i++) { + const state = await pool.query("SELECT 1 FROM pg_stat_activity WHERE wait_event_type='Lock' AND query LIKE '%loan_lenders%' LIMIT 1"); + if (state.rowCount) { blocked = true; break; } await delay(20); + } + assert.ok(blocked, `balance confirmation must wait for ${change} writer`); + if (change === 'identity') await sourceWriter.query('UPDATE loan_lenders SET identity_version=identity_version+1 WHERE id=$1', [balanceRace.lender.id]); + else await sourceWriter.query('UPDATE loan_evidences SET original_name=$1 WHERE id=$2', ['synthetic-changed.pdf', balanceRace.loans[1].original.id]); + await sourceWriter.query('COMMIT'); + const response = await pending; assert.equal(response.statusCode, 409); + assert.equal(response.json().error.details.reasonCode, 'loan.evidence_required'); + assert.equal((await prisma.companyLoan.findUniqueOrThrow({ where: { id: balanceRace.loans[0].id } })).confirmation_status, 'unconfirmed'); + } finally { await sourceWriter.query('ROLLBACK'); sourceWriter.release(); if (pending) await pending; } + } + checks.push('balance_consumption_serializes_identity_and_group_original_changes_and_rechecks_after_wait'); + + const paperCurrent = await call(finance, 'GET', `/company-loans?lenderId=${ordinaryCase.lender.id}`); + const paperLoan = paperCurrent.items.find(l => l.id === paper.id); + assert.equal(paperLoan.lenderAcknowledgementRenewable, false); + await call(finance, 'POST', root, body({ ...ordinaryCase, loans: [paperLoan] }), 409, 'loan.lender_ack_scope_invalid'); + for (const change of ['original', 'employee_refresh']) { + const renewal = await setup('manager', 2); const previous = await propose(renewal); await decide(renewal, previous); + for (let i = 0; i < renewal.loans.length; i++) { + await upload(renewal.loans[i], 'accounting_confirmation'); renewal.loans[i] = await confirmBalance(renewal.loans[i]); + } + async function grant() { + const authorization = await call(finance, 'POST', '/loan-related-authorizations', { lenderId: renewal.lender.id, + loanIds: renewal.loans.map(l => l.id), basis: '合成声明恢复后仍需独立办理公司代表授权', requestKey: randomUUID() }); + await call(renewal.signer, 'POST', `/loan-related-authorizations/${authorization.id}/actions`, { action: 'confirm', reason: '合成公司代表身份确认' }); + } + await grant(); + const before = await prisma.companyLoan.findUniqueOrThrow({ where: { id: renewal.loans[0].id } }); + if (change === 'original') await upload(renewal.loans[1]); + else { + await prisma.employee.update({ where: { id: renewal.signer.id }, data: { name: renewal.lender.name, + bank_institution_id: h.bank.id, bank_name: '合成建行员工收款资料' } }); + const source = await call(finance, 'GET', `/loan-lenders/employee-source/${renewal.signer.id}?legalEntityId=${renewal.lender.legalEntityId}`); + renewal.lender = await call(finance, 'POST', `/loan-lenders/${renewal.lender.id}/employee-refresh`, { + expectedSourceVersion: source.sourceVersion, expectedIdentityVersion: renewal.lender.identityVersion, + expectedAccountVersion: renewal.lender.accountVersion, detailsConfirmed: true, reason: '合成刷新员工收款资料' }); + } + assert.equal((await call(renewal.signer, 'GET', `${root}/${previous.id}`)).status, 'invalid'); + const payBody = { requestKey: randomUUID(), lenderId: renewal.lender.id, reason: '合成验证续办还款', + allocations: [{ loanId: renewal.loans[0].id, amount: '10.00' }] }; + await call(finance, 'POST', '/loan-repayments', payBody, 409, 'loan.evidence_required'); + const projected = await call(finance, 'GET', `/company-loans?lenderId=${renewal.lender.id}`); + assert.ok(projected.items.every(l => l.lenderAcknowledgementRenewable)); + // Renew one member of the invalid group; returning it does not trap renewal. + const selected = { ...renewal, loans: [renewal.loans[0]] }; + const back = await propose(selected); await decide(selected, back, 'return'); + const freshAck = await propose(selected); await decide(selected, freshAck); + assert.equal((await call(renewal.signer, 'GET', `${root}/${previous.id}`)).status, 'invalid'); + assert.deepEqual(await prisma.companyLoan.findUniqueOrThrow({ where: { id: renewal.loans[0].id } }), before, + 'renewal never edits the loan or its original balance confirmation'); + if (change === 'employee_refresh') { + const refusedPayment = await app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers, payload: payBody }); + assert.equal(refusedPayment.statusCode, 409, 'lender statement does not renew company authority'); + await grant(); + } + await call(finance, 'POST', '/loan-repayments', payBody); + } + checks.push('confirmed_balance_can_renew_invalid_statement_after_original_or_employee_refresh_without_rewriting_loan'); + checks.push('renewal_supports_return_and_partial_group_but_does_not_replace_company_authority_or_open_paper_only_scope'); + + const browserScenarios = []; + if (process.env.RUN_ISOLATED_LOAN_BROWSER === '1') for (const mode of ['desktop', 'mobile']) browserScenarios.push({ mode, ...await setup('employee', 4) }); + console.log(JSON.stringify({ stage: 'loan_lender_ack_http', status: 'PASS', checks })); + return { browserScenarios, upload, loan, body, decide, propose, setup, checks }; +}; diff --git a/scripts/qa/company-loan-reconciliation-browser.cjs b/scripts/qa/company-loan-reconciliation-browser.cjs index 8fff99ca..510f4abe 100644 --- a/scripts/qa/company-loan-reconciliation-browser.cjs +++ b/scripts/qa/company-loan-reconciliation-browser.cjs @@ -10,6 +10,7 @@ module.exports = async function reconciliationBrowser({ browser, origin, output, for (const mobile of [false, true]) { const mode = mobile ? 'mobile' : 'desktop'; let view; + const uploadedByKind = new Map(); async function login(who, batch) { if (view) await view.context.close(); const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); @@ -28,22 +29,34 @@ module.exports = async function reconciliationBrowser({ browser, origin, output, const dialog = view.page.getByRole('dialog'); if (fill) await fill(dialog); if (await dialog.getByLabel('依据与说明').count()) await dialog.getByLabel('依据与说明').fill('合成银行材料唯一定位本批次并独立核对全部指令'); + const invalid = await dialog.locator('input:invalid, select:invalid, textarea:invalid').evaluateAll(elements => + elements.map(el => el.getAttribute('aria-label') ?? el.labels?.[0]?.textContent?.trim().slice(0, 80) ?? el.tagName)); + assert.deepEqual(invalid, [], `synthetic UI ${name}: invalid form controls`); await dialog.getByRole('button', { name: '确认', exact: true }).click(); try { await expect(dialog).not.toBeVisible(); } - catch { throw new Error(`synthetic UI ${name}: ${await dialog.getByRole('alert').allTextContents()}`); } + catch { + await view.page.screenshot({ path: path.join(output, `${mode}-reconciliation-failure.png`), fullPage: true }); + throw new Error(`synthetic UI ${name}: ${await dialog.getByRole('alert').allTextContents()}; submit=${await dialog.locator('button[type="submit"]').textContent()}`); + } } async function review(name = '独立复核银行事实') { await action(name, d => d.getByLabel('复核结论').selectOption('approve')); } async function upload(kind) { + const uploaded = view.page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/loan-terminal-evidences') && + response.request().method() === 'POST'); await action('上传银行证据', async d => { await d.getByLabel('证据类别').selectOption(kind); await d.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await fixtures.bankLetterPdf() }); }); + const response = await uploaded; assert.equal(response.status(), 200); + uploadedByKind.set(kind, (await response.json()).data.id); } async function bankFields(d, s, amount, reference, incoming = false) { - const options = d.getByLabel('对应的成功银行回单').locator('option'); - await d.getByLabel('对应的成功银行回单').selectOption({ index: (await options.count()) - 1 }); + const evidenceId = uploadedByKind.get(incoming ? 'incoming_return' : 'late_success'); + assert.ok(evidenceId, 'use the specific uploaded document, never the placeholder or an older option'); + await d.getByLabel('对应的成功银行回单').selectOption(evidenceId); + await expect(d.getByLabel('对应的成功银行回单')).toHaveValue(evidenceId); await d.getByLabel('回单付款账号').fill(incoming ? s.payee : s.payer); await d.getByLabel('回单收款账号').fill(incoming ? s.payer : s.payee); await d.getByLabel('回单人民币金额').fill(amount); diff --git a/scripts/qa/company-loan-related-browser.cjs b/scripts/qa/company-loan-related-browser.cjs index 5804d2dd..6847a984 100644 --- a/scripts/qa/company-loan-related-browser.cjs +++ b/scripts/qa/company-loan-related-browser.cjs @@ -18,11 +18,16 @@ module.exports = async function relatedBrowser(h) { try { for (const s of relatedParty.ui) { const mobile = s.mode === 'mobile'; const prefix = mobile ? '/mobile' : ''; const contexts = []; + const technicalFinanceRequests = []; console.log(JSON.stringify({ stage: 'loan_related_browser_start', mode: s.mode })); async function login(who, route = `${prefix}/company-loans`) { const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' }); contexts.push(context); await context.route('**/*', r => r.request().url().startsWith(`${origin}/`) ? r.continue() : r.abort()); const p = await context.newPage(); + if (who.id === ordinary.id) p.on('request', request => { + const pathname = new URL(request.url()).pathname; + if (/^\/api\/(company-loans|loan-lenders|loan-repayments|loan-related-authorizations)(\/|$)/.test(pathname)) technicalFinanceRequests.push(pathname); + }); await p.goto(`${origin}/login?redirect=${encodeURIComponent(route)}`); await p.getByTestId('login-identifier').fill(who.email); await p.getByTestId('login-password').fill(password); await p.getByTestId('login-submit').click(); await p.waitForURL(url => !url.pathname.includes('login')); @@ -237,7 +242,12 @@ module.exports = async function relatedBrowser(h) { assert.equal(await mp.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false); await mp.screenshot({ path: path.join(output, `related-${s.mode}-closed.png`), fullPage: true }); checks.push(`${s.mode}_related_evidenced_duplicate_dismissal_and_no_overflow`); - const tech = await login(ordinary); await tech.waitForURL(/\/403$/); await expect(tech.getByRole('heading', { name: '公司借款', exact: true })).toHaveCount(0); + const tech = await login(ordinary); + await expect(tech.getByRole('heading', { name: '出借人确认借款', exact: true })).toBeVisible(); + await expect(tech.getByText('暂无可查看的出借人确认。', { exact: true })).toBeVisible(); + await expect(tech.getByRole('tablist')).toHaveCount(0); + await expect(tech.getByRole('button', { name: /登记借入|登记出借人|申请还本|发起出借人确认|登记公司代表授权范围|代表公司确认授权范围|停止新付款授权/ })).toHaveCount(0); + assert.deepEqual(technicalFinanceRequests, [], 'technical employee own declarations must not load finance or company-authority data'); assert.equal(await prisma.notification.count({ where: { recipient_employee_id: ordinary.id, target_id: { in: [b.id, failed.id, old.id] } } }), 0); // Stopping from the actual scope card uses the current grant only. await mp.goto(`${origin}${prefix}/company-loans`); diff --git a/server/prisma/migrations/20261010010000_loan_lender_acknowledgements/migration.sql b/server/prisma/migrations/20261010010000_loan_lender_acknowledgements/migration.sql new file mode 100644 index 00000000..ee7f48b3 --- /dev/null +++ b/server/prisma/migrations/20261010010000_loan_lender_acknowledgements/migration.sql @@ -0,0 +1,161 @@ +-- N5-C: lender's statement is separate from company-representative authority. +-- Additive only; no historical acknowledgement or balance is manufactured. +CREATE TABLE loan_lender_acknowledgements ( + id UUID PRIMARY KEY, + legal_entity_id UUID NOT NULL, + lender_id UUID NOT NULL, + employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, + identity_version INTEGER NOT NULL CHECK(identity_version>0), + account_version INTEGER NOT NULL CHECK(account_version>0), + lender_name VARCHAR(120) NOT NULL, + company_name VARCHAR(200) NOT NULL, + loan_ids UUID[] NOT NULL CHECK(cardinality(loan_ids) BETWEEN 1 AND 50), + content_hash VARCHAR(64) NOT NULL CHECK(content_hash ~ '^[a-f0-9]{64}$'), + request_hash VARCHAR(64) NOT NULL CHECK(request_hash ~ '^[a-f0-9]{64}$'), + reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2), + proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, + proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + request_key UUID NOT NULL, + invalidated_at TIMESTAMP(3), + invalid_reason VARCHAR(40), + CHECK(proposed_by<>employee_id), + CHECK((invalidated_at IS NULL)=(invalid_reason IS NULL)), + FOREIGN KEY(legal_entity_id,lender_id) REFERENCES loan_lenders(legal_entity_id,id) ON DELETE RESTRICT, + UNIQUE(proposed_by,request_key) +); +CREATE INDEX loan_lender_acknowledgements_employee_id_proposed_at_idx ON loan_lender_acknowledgements(employee_id,proposed_at); +CREATE INDEX loan_lender_acknowledgements_lender_id_proposed_at_idx ON loan_lender_acknowledgements(lender_id,proposed_at); +CREATE TABLE loan_lender_acknowledgement_loans ( + acknowledgement_id UUID NOT NULL REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT, + loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT, + snapshot_json JSONB NOT NULL, + PRIMARY KEY(acknowledgement_id,loan_id) +); +CREATE INDEX loan_lender_acknowledgement_loans_loan_id_idx ON loan_lender_acknowledgement_loans(loan_id); +CREATE TABLE loan_lender_acknowledgement_decisions ( + acknowledgement_id UUID PRIMARY KEY REFERENCES loan_lender_acknowledgements(id) ON DELETE RESTRICT, + action VARCHAR(16) NOT NULL CHECK(action IN ('confirm','return')), + actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, + reason VARCHAR(500) NOT NULL CHECK(length(btrim(reason))>=2), + original_checked BOOLEAN NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(action<>'confirm' OR original_checked) +); + +-- One canonical projection, excluding changing payment balances and ordinary +-- row versions. Adding accounting evidence / confirming balance keeps it valid. +CREATE FUNCTION loan_lender_ack_snapshot(loan_id_arg UUID) RETURNS JSONB LANGUAGE sql STABLE AS $$ + SELECT jsonb_build_object( + 'loanId',l.id,'loanNo',l.loan_no,'lenderId',l.lender_id,'legalEntityId',l.legal_entity_id, + 'originReference',l.origin_reference,'currency',l.currency_code, + 'originalPrincipal',l.original_principal::text, + 'openingOutstandingPrincipal',l.opening_outstanding_principal::text, + 'receivedDate',to_char(l.received_date,'YYYY-MM-DD'), + 'balanceAsOf',to_char(l.balance_as_of,'YYYY-MM-DD'),'historicalEntry',l.historical_entry, + 'originals',COALESCE((SELECT jsonb_agg(jsonb_build_object( + 'id',e.id,'sha256',e.file_sha256,'originalName',e.original_name, + 'fileSize',e.file_size,'mimeType',e.mime_type) ORDER BY e.id) + FROM loan_evidences e WHERE e.loan_id=l.id AND e.kind='incoming' + AND e.archived_at IS NULL AND e.governance_status='approved'),'[]'::jsonb)) + FROM company_loans l WHERE l.id=loan_id_arg +$$; + +CREATE FUNCTION guard_loan_lender_ack_history() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE a loan_lender_acknowledgements; l loan_lenders; c company_loans; +BEGIN + IF TG_TABLE_NAME='loan_lender_acknowledgements' AND TG_OP='UPDATE' THEN + IF OLD.invalidated_at IS NOT NULL OR NEW.invalidated_at IS NULL OR NEW.invalid_reason IS NULL OR + (to_jsonb(NEW)-'invalidated_at'-'invalid_reason') IS DISTINCT FROM (to_jsonb(OLD)-'invalidated_at'-'invalid_reason') + THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF; + RETURN NEW; + END IF; + IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'lender acknowledgement history is immutable' USING ERRCODE='23514'; END IF; + IF TG_TABLE_NAME='loan_lender_acknowledgements' THEN + SELECT * INTO l FROM loan_lenders WHERE id=NEW.lender_id FOR UPDATE; + IF l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM NEW.employee_id OR + l.identity_version<>NEW.identity_version OR l.account_version<>NEW.account_version OR + l.name<>NEW.lender_name OR NEW.company_name IS DISTINCT FROM (SELECT name FROM legal_entities WHERE id=l.legal_entity_id) OR + NEW.invalidated_at IS NOT NULL OR NEW.loan_ids IS DISTINCT FROM ARRAY(SELECT DISTINCT unnest(NEW.loan_ids) ORDER BY 1) + THEN RAISE EXCEPTION 'invalid lender acknowledgement source' USING ERRCODE='23514'; END IF; + ELSE + SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=NEW.acknowledgement_id; + IF TG_TABLE_NAME='loan_lender_acknowledgement_loans' THEN + SELECT * INTO c FROM company_loans WHERE id=NEW.loan_id; + IF NOT (NEW.loan_id=ANY(a.loan_ids)) OR c.lender_id<>a.lender_id OR c.legal_entity_id<>a.legal_entity_id OR + (c.confirmation_status<>'unconfirmed' AND NOT EXISTS( + SELECT 1 FROM loan_lender_acknowledgement_loans old_scope + JOIN loan_lender_acknowledgements old_ack ON old_ack.id=old_scope.acknowledgement_id + WHERE old_scope.loan_id=c.id AND old_ack.invalidated_at IS NOT NULL)) OR + NEW.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(NEW.loan_id) OR + jsonb_array_length(NEW.snapshot_json->'originals')=0 OR + EXISTS(SELECT 1 FROM loan_lender_acknowledgement_decisions WHERE acknowledgement_id=a.id) + THEN RAISE EXCEPTION 'invalid lender acknowledgement scope' USING ERRCODE='23514'; END IF; + ELSE + -- Same lock order as the service and source maintenance. Never take the + -- acknowledgement first and then wait for its source loan/lender. + SELECT * INTO l FROM loan_lenders WHERE id=a.lender_id FOR UPDATE; + PERFORM id FROM company_loans WHERE id=ANY(a.loan_ids) ORDER BY id FOR UPDATE; + SELECT * INTO a FROM loan_lender_acknowledgements WHERE id=a.id FOR UPDATE; + IF a.invalidated_at IS NOT NULL OR NEW.actor_id<>a.employee_id OR NEW.actor_id=a.proposed_by OR + l.archived_at IS NOT NULL OR l.identity_status<>'verified' OR l.employee_id IS DISTINCT FROM a.employee_id OR + l.identity_version<>a.identity_version OR l.account_version<>a.account_version OR + NOT EXISTS(SELECT 1 FROM employees WHERE id=NEW.actor_id AND status='active' AND archived_at IS NULL) OR + EXISTS(SELECT 1 FROM loan_lender_acknowledgement_loans s WHERE s.acknowledgement_id=a.id + AND s.snapshot_json IS DISTINCT FROM loan_lender_ack_snapshot(s.loan_id)) OR + (SELECT count(*) FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=a.id)<>cardinality(a.loan_ids) + THEN RAISE EXCEPTION 'invalid lender acknowledgement decision' USING ERRCODE='23514'; END IF; + END IF; + END IF; + RETURN NEW; +END $$; +CREATE TRIGGER lender_ack_header_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgements FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history(); +CREATE TRIGGER lender_ack_loan_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_loans FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history(); +CREATE TRIGGER lender_ack_decision_history BEFORE INSERT OR UPDATE OR DELETE ON loan_lender_acknowledgement_decisions FOR EACH ROW EXECUTE FUNCTION guard_loan_lender_ack_history(); + +CREATE FUNCTION check_loan_lender_ack_complete() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + IF NEW.loan_ids IS DISTINCT FROM (SELECT array_agg(loan_id ORDER BY loan_id) + FROM loan_lender_acknowledgement_loans WHERE acknowledgement_id=NEW.id) + THEN RAISE EXCEPTION 'incomplete lender acknowledgement snapshot' USING ERRCODE='23514'; END IF; + RETURN NULL; +END $$; +CREATE CONSTRAINT TRIGGER lender_ack_complete AFTER INSERT ON loan_lender_acknowledgements + DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_lender_ack_complete(); + +CREATE FUNCTION invalidate_loan_lender_ack() RETURNS trigger LANGUAGE plpgsql AS $$ +DECLARE affected UUID[]; +BEGIN + IF TG_TABLE_NAME='company_loans' THEN + IF ROW(OLD.lender_id,OLD.legal_entity_id,OLD.loan_no,OLD.origin_reference,OLD.currency_code, + OLD.original_principal,OLD.opening_outstanding_principal,OLD.received_date,OLD.balance_as_of,OLD.historical_entry) + IS NOT DISTINCT FROM ROW(NEW.lender_id,NEW.legal_entity_id,NEW.loan_no,NEW.origin_reference,NEW.currency_code, + NEW.original_principal,NEW.opening_outstanding_principal,NEW.received_date,NEW.balance_as_of,NEW.historical_entry) + THEN RETURN NULL; END IF; + affected:=ARRAY[NEW.id]; + ELSIF TG_TABLE_NAME='loan_lenders' THEN + IF ROW(OLD.employee_id,OLD.identity_status,OLD.identity_version,OLD.account_version,OLD.archived_at, + OLD.name,OLD.bank_account_encrypted,OLD.account_name_encrypted,OLD.bank_institution_id,OLD.bank_name) + IS NOT DISTINCT FROM ROW(NEW.employee_id,NEW.identity_status,NEW.identity_version,NEW.account_version,NEW.archived_at, + NEW.name,NEW.bank_account_encrypted,NEW.account_name_encrypted,NEW.bank_institution_id,NEW.bank_name) + THEN RETURN NULL; END IF; + UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP,invalid_reason='lender_changed' + WHERE lender_id=NEW.id AND invalidated_at IS NULL; + RETURN NULL; + ELSE + IF TG_OP='INSERT' THEN + IF NEW.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[NEW.loan_id]; + ELSIF TG_OP='DELETE' THEN + IF OLD.kind<>'incoming' THEN RETURN NULL; END IF; affected:=ARRAY[OLD.loan_id]; + ELSE + IF (OLD.kind<>'incoming' AND NEW.kind<>'incoming') OR OLD IS NOT DISTINCT FROM NEW THEN RETURN NULL; END IF; + affected:=ARRAY[OLD.loan_id,NEW.loan_id]; + END IF; + END IF; + UPDATE loan_lender_acknowledgements SET invalidated_at=CURRENT_TIMESTAMP, + invalid_reason=CASE WHEN TG_TABLE_NAME='company_loans' THEN 'loan_changed' ELSE 'original_changed' END + WHERE invalidated_at IS NULL AND loan_ids && affected; + RETURN NULL; +END $$; +CREATE TRIGGER lender_ack_loan_source AFTER UPDATE ON company_loans FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack(); +CREATE TRIGGER lender_ack_lender_source AFTER UPDATE ON loan_lenders FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack(); +CREATE TRIGGER lender_ack_original_source AFTER INSERT OR UPDATE OR DELETE ON loan_evidences FOR EACH ROW EXECUTE FUNCTION invalidate_loan_lender_ack(); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 164471e5..6f3e17c8 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -3935,6 +3935,60 @@ model LoanRelatedAuthorizationEvent { @@map("loan_related_authorization_events") } +model LoanLenderAcknowledgement { + id String @id @default(uuid()) @db.Uuid + legal_entity_id String @db.Uuid + lender_id String @db.Uuid + employee_id String @db.Uuid + identity_version Int + account_version Int + lender_name String @db.VarChar(120) + company_name String @db.VarChar(200) + loan_ids String[] @db.Uuid + content_hash String @db.VarChar(64) + request_hash String @db.VarChar(64) + reason String @db.VarChar(500) + proposed_by String @db.Uuid + proposed_at DateTime @default(now()) + request_key String @db.Uuid + invalidated_at DateTime? + invalid_reason String? @db.VarChar(40) + + loans LoanLenderAcknowledgementLoan[] + decision LoanLenderAcknowledgementDecision? + + @@unique([proposed_by, request_key]) + @@index([employee_id, proposed_at]) + @@index([lender_id, proposed_at]) + @@map("loan_lender_acknowledgements") +} + +model LoanLenderAcknowledgementLoan { + acknowledgement_id String @db.Uuid + loan_id String @db.Uuid + snapshot_json Json + + acknowledgement LoanLenderAcknowledgement @relation(fields: [acknowledgement_id], references: [id], onDelete: Restrict) + loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict) + + @@id([acknowledgement_id, loan_id]) + @@index([loan_id]) + @@map("loan_lender_acknowledgement_loans") +} + +model LoanLenderAcknowledgementDecision { + acknowledgement_id String @id @db.Uuid + action String @db.VarChar(16) + actor_id String @db.Uuid + reason String @db.VarChar(500) + original_checked Boolean + created_at DateTime @default(now()) + + acknowledgement LoanLenderAcknowledgement @relation(fields: [acknowledgement_id], references: [id], onDelete: Restrict) + + @@map("loan_lender_acknowledgement_decisions") +} + model LoanLender { id String @id @default(uuid()) @db.Uuid legal_entity_id String @db.Uuid @@ -3998,6 +4052,7 @@ model CompanyLoan { evidences LoanEvidence[] supplemental_settlements LoanSupplementalSettlement[] + lender_acknowledgements LoanLenderAcknowledgementLoan[] @@unique([created_by, request_key]) @@unique([legal_entity_id, lender_id, origin_reference]) diff --git a/server/src/constants/errorReasons.js b/server/src/constants/errorReasons.js index 70e49c5a..15096053 100644 --- a/server/src/constants/errorReasons.js +++ b/server/src/constants/errorReasons.js @@ -29,7 +29,11 @@ const ERROR_REASONS = [ {"code":"loan.account_unavailable","domain":"loan","audience":"user","defaultMessage":"借款收款资料暂不可用,请联系财务核对账户加密配置。","recovery":"follow_guidance"}, {"code":"loan.duplicate_registration","domain":"loan","audience":"user","defaultMessage":"该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。","recovery":"follow_guidance"}, {"code":"loan.confirmed_immutable","domain":"loan","audience":"user","defaultMessage":"借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。","recovery":"follow_guidance"}, - {"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"}, + {"code":"loan.lender_ack_scope_invalid","domain":"loan","audience":"user","defaultMessage":"请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。","recovery":"follow_guidance"}, + {"code":"loan.lender_ack_exists","domain":"loan","audience":"user","defaultMessage":"所选借款已有待确认或有效的出借人声明,请查看现有记录。","recovery":"follow_guidance"}, + {"code":"loan.lender_ack_changed","domain":"loan","audience":"user","defaultMessage":"借款内容、原件或出借人资料已变化,请由财务重新发起确认。","recovery":"follow_guidance"}, + {"code":"loan.lender_ack_decided","domain":"loan","audience":"user","defaultMessage":"这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。","recovery":"follow_guidance"}, + {"code":"loan.evidence_required","domain":"loan","audience":"user","defaultMessage":"请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。","recovery":"follow_guidance"}, {"code":"loan.evidence_invalid","domain":"loan","audience":"user","defaultMessage":"借款材料不符合要求,请使用有效的 PDF 或图片文件。","recovery":"follow_guidance"}, {"code":"loan.balance_unconfirmed","domain":"loan","audience":"user","defaultMessage":"借款余额尚未核实,不能申请归还本金。","recovery":"follow_guidance"}, {"code":"loan.allocation_invalid","domain":"loan","audience":"user","defaultMessage":"分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。","recovery":"follow_guidance"}, diff --git a/server/src/constants/notificationTypes.js b/server/src/constants/notificationTypes.js index f0d5baa1..082e8fa2 100644 --- a/server/src/constants/notificationTypes.js +++ b/server/src/constants/notificationTypes.js @@ -20,6 +20,7 @@ const NOTIFICATION_TYPES = Object.freeze({ paymentBatchReviewRequested: 'payment_batch.review_requested', paymentBatchExecutionRequested: 'payment_batch.execution_requested', paymentBatchReturned: 'payment_batch.returned', + loanLenderAcknowledgementRequested: 'loan.lender_acknowledgement_requested', leaveApproved: 'leave.approved', leaveRejected: 'leave.rejected', leaveReturned: 'leave.returned', diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index 61dda471..8a374522 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -41,6 +41,7 @@ const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"]; const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"]; const EMPLOYEE_BASE_PERMISSIONS = [ + "loan.acknowledge_own", ...EXPENSE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS, ...ATTENDANCE_SELF_SERVICE_PERMISSIONS, diff --git a/server/src/routes/companyLoans.js b/server/src/routes/companyLoans.js index 2477d153..9fd49b53 100644 --- a/server/src/routes/companyLoans.js +++ b/server/src/routes/companyLoans.js @@ -16,6 +16,7 @@ const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, b const approvalPolicy = require("../services/loanApprovalPolicy"); const employeeLenders = require("../services/loanLenderEmployee"); const incomingReceipts = require("../services/loanIncomingReceipt"); +const lenderAcknowledgements = require("../services/loanLenderAcknowledgement"); const UUID = z.string().uuid(); const DATE = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(isValidDateOnlyString); @@ -45,6 +46,11 @@ const CONFIRM = z.object({ expectedVersion: VERSION, accountingConfirmationRef: const EVIDENCE_KIND = z.enum(["agreement", "incoming", "accounting_confirmation", "adjustment"]); const LIST = z.object({ lenderId: UUID.optional(), page: z.coerce.number().int().min(1).default(1), pageSize: z.coerce.number().int().min(1).max(100).default(30) }).strict(); +const ACK_PROPOSE = z.object({ lenderId: UUID, expectedIdentityVersion: VERSION, requestKey: UUID, reason: TEXT, + loans: z.array(z.object({ id: UUID, version: VERSION }).strict()).min(1).max(50) }).strict(); +const ACK_DECIDE = z.object({ action: z.enum(["confirm", "return"]), reason: TEXT, + contentHash: z.string().regex(/^[a-f0-9]{64}$/), originalChecked: z.boolean() }).strict() + .refine((value) => value.action !== "confirm" || value.originalChecked); const MAX_BYTES = 10 * 1024 * 1024; function parse(schema, value) { @@ -110,6 +116,41 @@ async function companyLoansRoutes(fastify) { const auth = { preHandler: fastify.authenticate }; const isFinance = (request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage"); + function ensureAcknowledgementReader(request) { + if (!isFinance(request) && !fastify.hasPermission(request.employee, "loan.acknowledge_own")) ensureLoanManager(fastify, request); + } + fastify.get("/loan-lender-acknowledgements", auth, async (request, reply) => { + ensureAcknowledgementReader(request); + const query = parse(LIST.omit({ lenderId: true }), request.query ?? {}); + const where = isFinance(request) ? {} : { employee_id: request.employee.id }; + const [items, total] = await Promise.all([ + fastify.prisma.loanLenderAcknowledgement.findMany({ where, include: lenderAcknowledgements.INCLUDE, + orderBy: [{ proposed_at: "desc" }, { id: "asc" }], skip: (query.page - 1) * query.pageSize, take: query.pageSize }), + fastify.prisma.loanLenderAcknowledgement.count({ where }), + ]); + reply.header("Cache-Control", "private, no-store"); + return sendData(reply, request, { items: await Promise.all(items.map((record) => + lenderAcknowledgements.present(fastify.prisma, record, request.employee.id))), total }); + }); + fastify.get("/loan-lender-acknowledgements/:id", auth, async (request, reply) => { + ensureAcknowledgementReader(request); + const record = await lenderAcknowledgements.read(fastify.prisma, fastify, request, parse(UUID, request.params.id)); + reply.header("Cache-Control", "private, no-store"); + return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id)); + }); + fastify.post("/loan-lender-acknowledgements", auth, async (request, reply) => { + ensureLoanManager(fastify, request); + const record = await lenderAcknowledgements.propose(fastify.prisma, fastify, request, parse(ACK_PROPOSE, request.body)); + reply.header("Cache-Control", "private, no-store"); + return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id)); + }); + fastify.post("/loan-lender-acknowledgements/:id/decision", auth, async (request, reply) => { + ensureAcknowledgementReader(request); + const record = await lenderAcknowledgements.decide(fastify.prisma, fastify, request, + parse(UUID, request.params.id), parse(ACK_DECIDE, request.body)); + reply.header("Cache-Control", "private, no-store"); + return sendData(reply, request, await lenderAcknowledgements.present(fastify.prisma, record, request.employee.id)); + }); async function readAuthorization(request, id) { const record = await fastify.prisma.loanRelatedAuthorization.findUnique({ where: { id }, include: approvalPolicy.INCLUDE }); if (!record) throw loanNotFound(); @@ -366,7 +407,13 @@ async function companyLoansRoutes(fastify) { ensureLoanManager(fastify, request); const id = parse(UUID, request.params.id); const body = parse(CONFIRM, request.body); const item = await fastify.prisma.$transaction(async (tx) => { + const target = await tx.companyLoan.findUnique({ where: { id }, select: { lender_id: true } }); + if (!target) throw loanNotFound(); + // Serialize consuming an acknowledgement with identity/original changes, + // including changes to another loan in the same frozen statement. + await lockLender(tx, target.lender_id); const loan = await lockLoan(tx, id); + if (loan.lender_id !== target.lender_id) throw loanNotFound(); assertCurrentVersion(loan.version, body.expectedVersion); if (loan.confirmation_status === "confirmed") throw buildBusinessValidationError("loan.confirmed_immutable", {}, 409); assertEvidenceComplete(loan); @@ -431,11 +478,16 @@ async function companyLoansRoutes(fastify) { fastify.post("/company-loans/:id/evidences/:evidenceId/download", auth, async (request, reply) => { const finance = request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && fastify.hasPermission(request.employee, "loan.manage"); - if (!finance && (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review"))) ensureLoanManager(fastify, request); const id = parse(UUID, request.params.id); const evidenceId = parse(UUID, request.params.evidenceId); - const { reason, paymentBatchId, authorizationId } = parse(z.object({ reason: TEXT, paymentBatchId: UUID.optional(), authorizationId: UUID.optional() }).strict() - .refine((v) => !(v.paymentBatchId && v.authorizationId)), request.body); - if (!finance) { + const { reason, paymentBatchId, authorizationId, lenderAcknowledgementId } = parse(z.object({ reason: TEXT, + paymentBatchId: UUID.optional(), authorizationId: UUID.optional(), lenderAcknowledgementId: UUID.optional() }).strict() + .refine((v) => [v.paymentBatchId, v.authorizationId, v.lenderAcknowledgementId].filter(Boolean).length <= 1), request.body); + let acknowledgedOriginal; + if (lenderAcknowledgementId) { + ensureAcknowledgementReader(request); + acknowledgedOriginal = await lenderAcknowledgements.assertOriginalAccess(fastify.prisma, fastify, request, lenderAcknowledgementId, id, evidenceId); + } else if (!finance) { + if (!request.employee.roleCodes.some((r) => ["manager", "ceo"].includes(r)) || !fastify.hasPermission(request.employee, "payment.batch.review")) ensureLoanManager(fastify, request); if (authorizationId) { const record = await readAuthorization(request, authorizationId); if (!record.loans.some((loan) => loan.loan_id === id)) throw loanNotFound(); @@ -452,6 +504,7 @@ async function companyLoansRoutes(fastify) { } const item = await fastify.prisma.loanEvidence.findFirst({ where: { id: evidenceId, loan_id: id, archived_at: null, governance_status: "approved" } }); if (!item) throw loanNotFound(); + if (acknowledgedOriginal && acknowledgedOriginal.sha256 !== item.file_sha256) throw loanNotFound(); let validated; try { if (item.file_size > MAX_BYTES) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409); @@ -459,12 +512,15 @@ async function companyLoansRoutes(fastify) { validated = await validateEvidenceFile({ buffer: await boundedBuffer(opened.stream), fileName: item.original_name, declaredMimeType: item.mime_type, normalizeImages: false }); if (validated.fileSha256 !== item.file_sha256) throw buildBusinessValidationError("loan.evidence_invalid", {}, 409); + // A source/identity change during storage IO must not grant stale access. + if (lenderAcknowledgementId) await lenderAcknowledgements.assertOriginalAccess(fastify.prisma, fastify, request, lenderAcknowledgementId, id, evidenceId); } catch (error) { await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOAD_BLOCKED", id, { evidenceId }); if (error instanceof StorageNotFoundError) throw loanNotFound(); throw error; } await auditLoan(fastify.prisma, request, "COMPANY_LOAN_EVIDENCE_DOWNLOADED", id, { evidenceId, reason, + ...(lenderAcknowledgementId ? { lenderAcknowledgementId } : {}), ...(authorizationId ? { authorizationId } : {}), ...(paymentBatchId ? { paymentBatchId } : {}) }); return reply.header("Cache-Control", "private, no-store").header("X-Content-Type-Options", "nosniff") .header("Content-Security-Policy", "default-src 'none'; sandbox") diff --git a/server/src/services/companyLoans.js b/server/src/services/companyLoans.js index 895fa345..c1297cf0 100644 --- a/server/src/services/companyLoans.js +++ b/server/src/services/companyLoans.js @@ -3,9 +3,11 @@ const { AppError } = require("../utils/AppError"); const { buildBusinessValidationError } = require("../utils/businessValidationError"); const { buildAuditLogData } = require("../utils/audit"); const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("./crypto"); +const { validAcknowledgement, acknowledgementRenewable } = require("./loanLenderAcknowledgementSnapshot"); const LENDER_INCLUDE = { bank_institution: true, legal_entity: true }; -const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true }; +const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true, + lender_acknowledgements: { include: { acknowledgement: { include: { decision: true } } } } }; function ensureLoanManager(fastify, request) { if (!request.employee?.roleCodes?.some((role) => ["finance", "finance_mgr"].includes(role)) || @@ -80,6 +82,7 @@ function mapEvidence(evidence) { } function mapLoan(config, loan) { + const acknowledgement = validAcknowledgement(loan); return { id: loan.id, loanNo: loan.loan_no, originReference: loan.origin_reference, lender: mapLender(config, loan.lender), currency: loan.currency_code, @@ -89,6 +92,9 @@ function mapLoan(config, loan) { balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10), historicalEntry: loan.historical_entry, confirmationStatus: loan.confirmation_status, confirmedAt: loan.confirmed_at, version: loan.version, accountingConfirmationRef: loan.accounting_confirmation_ref, + lenderAcknowledgement: acknowledgement ? { id: acknowledgement.id, confirmedAt: acknowledgement.decision.created_at, + lenderName: acknowledgement.lender_name } : null, + lenderAcknowledgementRenewable: acknowledgementRenewable(loan), ...loanBalances(loan), evidences: (loan.evidences ?? []).map(mapEvidence), }; } @@ -126,7 +132,8 @@ async function inFlightLenderIds(db, lenderIds) { function assertEvidenceComplete(loan) { const kinds = new Set((loan.evidences ?? []).filter((e) => !e.archived_at && e.governance_status === "approved").map((e) => e.kind)); - const required = ["agreement", "incoming", "accounting_confirmation"]; + const required = ["incoming", "accounting_confirmation"]; + if (!validAcknowledgement(loan)) required.push("agreement"); if (new Prisma.Decimal(loan.opening_outstanding_principal).lt(loan.original_principal)) required.push("adjustment"); if (required.some((kind) => !kinds.has(kind))) throw buildBusinessValidationError("loan.evidence_required", {}, 409); } diff --git a/server/src/services/loanLenderAcknowledgement.js b/server/src/services/loanLenderAcknowledgement.js new file mode 100644 index 00000000..aaaabd1e --- /dev/null +++ b/server/src/services/loanLenderAcknowledgement.js @@ -0,0 +1,147 @@ +const { randomUUID, createHash } = require("node:crypto"); +const { Prisma } = require("@prisma/client"); +const { AppError } = require("../utils/AppError"); +const { buildBusinessValidationError } = require("../utils/businessValidationError"); +const { lockLender, auditLoan, loanNotFound, assertCurrentVersion } = require("./companyLoans"); +const { canonical, same, snapshot, matchesLender, acknowledgementRenewable } = require("./loanLenderAcknowledgementSnapshot"); +const { createNotifications } = require("./notifications"); +const { NOTIFICATION_TYPES } = require("../constants/notificationTypes"); +const INCLUDE = { loans: { orderBy: { loan_id: "asc" } }, decision: true }; +const SOURCE = { lender: true, evidences: true }; +const digest = (value) => createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex"); +const errors = { + "loan.lender_ack_changed": () => buildBusinessValidationError("loan.lender_ack_changed", {}, 409), + "loan.lender_ack_scope_invalid": () => buildBusinessValidationError("loan.lender_ack_scope_invalid", {}, 409), + "loan.lender_ack_exists": () => buildBusinessValidationError("loan.lender_ack_exists", {}, 409), + "loan.lender_ack_decided": () => buildBusinessValidationError("loan.lender_ack_decided", {}, 409), + "loan.duplicate_registration": () => buildBusinessValidationError("loan.duplicate_registration", {}, 409), +}; +const fail = (code = "loan.lender_ack_changed") => { throw errors[code](); }; +const forbidden = () => { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); }; +const isFinance = (fastify, request) => request.employee.roleCodes.some((r) => ["finance", "finance_mgr"].includes(r)) && + fastify.hasPermission(request.employee, "loan.manage"); + +async function lockLoans(tx, ids) { + await tx.$queryRaw`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids.map((id) => Prisma.sql`${id}::uuid`))}) ORDER BY id FOR UPDATE`; + return tx.companyLoan.findMany({ where: { id: { in: ids } }, include: { ...SOURCE, + lender_acknowledgements: { select: { acknowledgement: { select: { invalidated_at: true } } } } }, orderBy: { id: "asc" } }); +} +async function activeEmployee(tx, id, lock = false) { + if (lock) await tx.$queryRaw`SELECT id FROM employees WHERE id=${id}::uuid FOR NO KEY UPDATE`; + return tx.employee.findFirst({ where: { id, status: "active", archived_at: null }, select: { id: true, name: true } }); +} +function sourceMatches(record, loans) { + return !record.invalidated_at && record.loans.length === record.loan_ids.length && loans.length === record.loan_ids.length && + record.loans.every((item) => { + const loan = loans.find((l) => l.id === item.loan_id); + return loan && matchesLender(record, loan.lender) && same(item.snapshot_json, snapshot(loan)); + }); +} +async function read(db, fastify, request, id) { + const record = await db.loanLenderAcknowledgement.findFirst({ + where: { id, ...(isFinance(fastify, request) ? {} : { employee_id: request.employee.id }) }, include: INCLUDE, + }); + if (!record) throw loanNotFound(); + return record; +} +async function present(db, record, actorId) { + const [loans, maker, signer] = await Promise.all([ + db.companyLoan.findMany({ where: { id: { in: record.loan_ids } }, include: SOURCE }), + db.employee.findUnique({ where: { id: record.proposed_by }, select: { name: true } }), + activeEmployee(db, record.employee_id), + ]); + const current = sourceMatches(record, loans); + return { + id: record.id, lenderId: record.lender_id, legalEntityId: record.legal_entity_id, + lenderName: record.lender_name, companyName: record.company_name, + proposedByName: maker?.name ?? "原经办人", proposedAt: record.proposed_at, reason: record.reason, + contentHash: record.content_hash, current, invalidatedAt: record.invalidated_at, + status: !current ? "invalid" : record.decision?.action === "confirm" ? "confirmed" : record.decision ? "returned" : "pending", + decision: record.decision ? { action: record.decision.action, reason: record.decision.reason, + createdAt: record.decision.created_at, lenderName: record.lender_name } : null, + canDecide: current && !record.decision && Boolean(signer) && actorId === record.employee_id && actorId !== record.proposed_by, + canReadOriginals: current, + loans: record.loans.map((item) => item.snapshot_json), + }; +} +async function propose(prisma, fastify, request, input) { + const ids = input.loans.map((l) => l.id).sort(); + if (ids.length !== new Set(ids).size) fail("loan.lender_ack_scope_invalid"); + const requestHash = digest({ ...input, loans: [...input.loans].sort((a, b) => a.id.localeCompare(b.id)) }); + const lookup = (db) => db.loanLenderAcknowledgement.findUnique({ where: { + proposed_by_request_key: { proposed_by: request.employee.id, request_key: input.requestKey }, + }, include: INCLUDE }); + const matchRetry = (record) => { if (record.request_hash !== requestHash) fail("loan.duplicate_registration"); return record; }; + try { + return await prisma.$transaction(async (tx) => { + const lender = await lockLender(tx, input.lenderId); + const old = await lookup(tx); if (old) return matchRetry(old); + if (lender.archived_at || lender.identity_status !== "verified" || !lender.employee_id || lender.employee_id === request.employee.id) fail("loan.lender_ack_scope_invalid"); + assertCurrentVersion(lender.identity_version, input.expectedIdentityVersion); + const loans = await lockLoans(tx, ids); + if (loans.length !== ids.length || loans.some((loan) => loan.lender_id !== lender.id || + loan.legal_entity_id !== lender.legal_entity_id || + (loan.confirmation_status !== "unconfirmed" && !acknowledgementRenewable(loan)) || !snapshot(loan).originals.length)) fail("loan.lender_ack_scope_invalid"); + for (const loan of loans) assertCurrentVersion(loan.version, input.loans.find((l) => l.id === loan.id).version); + const existing = await tx.loanLenderAcknowledgement.findFirst({ where: { + lender_id: lender.id, invalidated_at: null, loan_ids: { hasSome: ids }, + OR: [{ decision: null }, { decision: { action: "confirm" } }], + } }); + if (existing) fail("loan.lender_ack_exists"); + if (!await activeEmployee(tx, lender.employee_id, true)) fail("loan.lender_ack_scope_invalid"); + const snapshots = loans.map(snapshot); + const record = await tx.loanLenderAcknowledgement.create({ data: { + id: randomUUID(), lender_id: lender.id, legal_entity_id: lender.legal_entity_id, employee_id: lender.employee_id, + identity_version: lender.identity_version, account_version: lender.account_version, + lender_name: lender.name, company_name: lender.legal_entity.name, loan_ids: ids, + content_hash: digest(snapshots), request_hash: requestHash, reason: input.reason, + proposed_by: request.employee.id, request_key: input.requestKey, + loans: { create: snapshots.map((value) => ({ loan_id: value.loanId, snapshot_json: value })) }, + }, include: INCLUDE }); + await auditLoan(tx, request, "LOAN_LENDER_ACK_PROPOSED", record.id, { loanIds: ids, contentHash: record.content_hash }); + await createNotifications(tx, { + recipientEmployeeIds: [record.employee_id], + type: NOTIFICATION_TYPES.loanLenderAcknowledgementRequested, + title: "借款声明待本人确认", + body: "财务已发起借款声明,请核对借款明细及原件后确认,或填写原因退回。", + targetType: "loan_lender_acknowledgement", targetId: record.id, + }); + return record; + }); + } catch (error) { + if (error?.code === "P2002") { const old = await lookup(prisma); if (old) return matchRetry(old); } + throw error; + } +} +async function decide(prisma, fastify, request, id, input) { + // Restrict before locking or disclosing the existence of someone else's request. + const initial = await read(prisma, fastify, request, id); + if (initial.employee_id !== request.employee.id || initial.proposed_by === request.employee.id) forbidden(); + return prisma.$transaction(async (tx) => { + await lockLender(tx, initial.lender_id); + const loans = await lockLoans(tx, initial.loan_ids); + await tx.$queryRaw`SELECT id FROM loan_lender_acknowledgements WHERE id=${id}::uuid FOR UPDATE`; + const record = await read(tx, fastify, request, id); + if (input.contentHash !== record.content_hash || !sourceMatches(record, loans)) fail(); + if (!await activeEmployee(tx, request.employee.id, true)) forbidden(); + if (record.decision) { + if (record.decision.actor_id === request.employee.id && record.decision.action === input.action && + record.decision.reason === input.reason && record.decision.original_checked === input.originalChecked) return record; + fail("loan.lender_ack_decided"); + } + await tx.loanLenderAcknowledgementDecision.create({ data: { acknowledgement_id: id, + action: input.action, actor_id: request.employee.id, reason: input.reason, original_checked: input.originalChecked } }); + await auditLoan(tx, request, input.action === "confirm" ? "LOAN_LENDER_ACK_CONFIRMED" : "LOAN_LENDER_ACK_RETURNED", id, + { loanIds: record.loan_ids, contentHash: record.content_hash }); + return tx.loanLenderAcknowledgement.findUniqueOrThrow({ where: { id }, include: INCLUDE }); + }); +} +async function assertOriginalAccess(db, fastify, request, acknowledgementId, loanId, evidenceId) { + const record = await read(db, fastify, request, acknowledgementId); + const loans = await db.companyLoan.findMany({ where: { id: { in: record.loan_ids } }, include: SOURCE }); + if (!sourceMatches(record, loans)) fail(); + const original = record.loans.find((l) => l.loan_id === loanId)?.snapshot_json?.originals?.find((e) => e.id === evidenceId); + if (!original) throw loanNotFound(); + return original; +} +module.exports = { INCLUDE, isFinance, read, present, propose, decide, assertOriginalAccess, sourceMatches }; diff --git a/server/src/services/loanLenderAcknowledgementSnapshot.js b/server/src/services/loanLenderAcknowledgementSnapshot.js new file mode 100644 index 00000000..d4d42b18 --- /dev/null +++ b/server/src/services/loanLenderAcknowledgementSnapshot.js @@ -0,0 +1,42 @@ +const { Prisma } = require("@prisma/client"); + +// Compare structured data independently of PostgreSQL JSONB key order. +function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])); + return value; +} +const same = (left, right) => JSON.stringify(canonical(left)) === JSON.stringify(canonical(right)); + +function snapshot(loan) { + return { + loanId: loan.id, loanNo: loan.loan_no, lenderId: loan.lender_id, legalEntityId: loan.legal_entity_id, + originReference: loan.origin_reference, currency: loan.currency_code, + originalPrincipal: new Prisma.Decimal(loan.original_principal).toFixed(2), + openingOutstandingPrincipal: new Prisma.Decimal(loan.opening_outstanding_principal).toFixed(2), + receivedDate: loan.received_date.toISOString().slice(0, 10), balanceAsOf: loan.balance_as_of.toISOString().slice(0, 10), + historicalEntry: loan.historical_entry, + originals: (loan.evidences ?? []).filter((e) => e.kind === "incoming" && !e.archived_at && e.governance_status === "approved") + .sort((a, b) => a.id.localeCompare(b.id)).map((e) => ({ id: e.id, sha256: e.file_sha256, + originalName: e.original_name, fileSize: e.file_size, mimeType: e.mime_type })), + }; +} +function matchesLender(record, lender) { + return Boolean(lender && !lender.archived_at && lender.identity_status === "verified" && + record.lender_id === lender.id && record.legal_entity_id === lender.legal_entity_id && + record.employee_id === lender.employee_id && record.identity_version === lender.identity_version && + record.account_version === lender.account_version && record.lender_name === lender.name); +} +function validAcknowledgement(loan) { + return (loan.lender_acknowledgements ?? []).find((item) => { + const record = item.acknowledgement; + return record && !record.invalidated_at && record.decision?.action === "confirm" && + record.decision.original_checked && record.decision.actor_id === record.employee_id && + record.employee_id !== record.proposed_by && record.loan_ids.includes(loan.id) && + matchesLender(record, loan.lender) && same(item.snapshot_json, snapshot(loan)); + })?.acknowledgement ?? null; +} +function acknowledgementRenewable(loan) { + return loan.confirmation_status === "confirmed" && Boolean(loan.lender_acknowledgements?.some((item) => item.acknowledgement?.invalidated_at)); +} +module.exports = { canonical, same, snapshot, matchesLender, validAcknowledgement, acknowledgementRenewable }; diff --git a/server/src/services/workspaceSettings.js b/server/src/services/workspaceSettings.js index 640bafcb..c54be1a3 100644 --- a/server/src/services/workspaceSettings.js +++ b/server/src/services/workspaceSettings.js @@ -348,9 +348,10 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission }) const financeNavigation = hasFinanceRole(hasPermission, employee); const paymentNavigation = financeNavigation || hasPaymentReviewerRole(hasPermission, employee); + const moduleFinanceNavigation = PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation; if ( FINANCE_NAVIGATION_MODULES.has(moduleKey) && - !(PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation) + !moduleFinanceNavigation ) { continue; } @@ -373,7 +374,7 @@ async function getSystemNavigation({ prisma, config, employee, hasPermission }) const section = resolveNavigationSection({ moduleKey, placement, - financeNavigation: PAYMENT_NAVIGATION_MODULES.has(moduleKey) ? paymentNavigation : financeNavigation, + financeNavigation: moduleFinanceNavigation, }); if (!section) { diff --git a/server/tests/unit/fixtures/error-reason-messages.json b/server/tests/unit/fixtures/error-reason-messages.json index 51d36838..850f0318 100644 --- a/server/tests/unit/fixtures/error-reason-messages.json +++ b/server/tests/unit/fixtures/error-reason-messages.json @@ -29,7 +29,11 @@ "loan.account_unavailable": "借款收款资料暂不可用,请联系财务核对账户加密配置。", "loan.duplicate_registration": "该借入凭据已登记,或此请求已使用,请查看原记录,勿重复登记。", "loan.confirmed_immutable": "借款余额已确认,不能覆盖修改;请保留原记录并发起纠错核查。", - "loan.evidence_required": "请先上传协议、原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。", + "loan.lender_ack_scope_invalid": "请选择同一公司、同一已关联员工出借人的待核实借款或声明已失效的借款,并先上传原借入凭据;经办人与出借人须分开。", + "loan.lender_ack_exists": "所选借款已有待确认或有效的出借人声明,请查看现有记录。", + "loan.lender_ack_changed": "借款内容、原件或出借人资料已变化,请由财务重新发起确认。", + "loan.lender_ack_decided": "这份借款声明已经处理,请查看确认记录;退回后可由财务重新发起。", + "loan.evidence_required": "请提供协议或有效的出借人在线确认,并上传原借入凭据和余额核对材料;期初余额低于原本金还须补齐系统外还款依据。", "loan.evidence_invalid": "借款材料不符合要求,请使用有效的 PDF 或图片文件。", "loan.balance_unconfirmed": "借款余额尚未核实,不能申请归还本金。", "loan.allocation_invalid": "分配金额超过可还本金,或借款不属于同一公司和出借人,请重新核对。", diff --git a/server/tests/unit/services/loanLenderAcknowledgement.test.js b/server/tests/unit/services/loanLenderAcknowledgement.test.js new file mode 100644 index 00000000..2c65b893 --- /dev/null +++ b/server/tests/unit/services/loanLenderAcknowledgement.test.js @@ -0,0 +1,81 @@ +const { assertEvidenceComplete } = require('../../../src/services/companyLoans'); +const { snapshot, validAcknowledgement, acknowledgementRenewable } = require('../../../src/services/loanLenderAcknowledgementSnapshot'); + +function fixture() { + const lender = { id: 'lender', legal_entity_id: 'company', employee_id: 'signer', name: '合成出借人', + identity_status: 'verified', identity_version: 1, account_version: 1, archived_at: null }; + const loan = { id: 'loan', loan_no: 'JK-SYN', origin_reference: 'SYN-IN', lender_id: lender.id, legal_entity_id: 'company', + currency_code: 'CNY', original_principal: '100', opening_outstanding_principal: '100', historical_entry: true, + received_date: new Date('2025-03-01'), balance_as_of: new Date('2026-01-01'), lender, + evidences: [{ id: 'original', kind: 'incoming', governance_status: 'approved', file_sha256: 'a'.repeat(64), + original_name: 'synthetic.pdf', mime_type: 'application/pdf', file_size: 123 }, + { id: 'accounting', kind: 'accounting_confirmation', governance_status: 'approved' }] }; + const record = { id: 'ack', lender_id: lender.id, legal_entity_id: 'company', employee_id: 'signer', lender_name: lender.name, + identity_version: 1, account_version: 1, proposed_by: 'finance', loan_ids: [loan.id], invalidated_at: null, + decision: { action: 'confirm', actor_id: 'signer', original_checked: true } }; + loan.lender_acknowledgements = [{ acknowledgement: record, snapshot_json: snapshot(loan) }]; + return { loan, record, lender }; +} +function refused(loan) { + expect(() => assertEvidenceComplete(loan)).toThrow(expect.objectContaining({ details: { reasonCode: 'loan.evidence_required' } })); +} + +describe('lender statement as one specific evidence alternative', () => { + it('accepts a personally confirmed statement with originals and accounting, without paper agreement', () => { + const { loan } = fixture(); expect(() => assertEvidenceComplete(loan)).not.toThrow(); + }); + it.each(['incoming', 'accounting_confirmation'])('never replaces %s evidence', (kind) => { + const { loan } = fixture(); loan.evidences = loan.evidences.filter(e => e.kind !== kind); refused(loan); + }); + it.each(['archived_at', 'governance_status'])('rejects unusable accounting evidence: %s', (field) => { + const { loan } = fixture(); loan.evidences[1][field] = field === 'archived_at' ? new Date() : 'pending'; refused(loan); + }); + it('still requires the separate basis for historical repayments', () => { + const { loan } = fixture(); loan.opening_outstanding_principal = '80'; + loan.lender_acknowledgements[0].snapshot_json = snapshot(loan); refused(loan); + loan.evidences.push({ kind: 'adjustment', governance_status: 'approved' }); + expect(() => assertEvidenceComplete(loan)).not.toThrow(); + }); + it('keeps the existing paper path when an online statement is absent or invalid', () => { + const { loan, record } = fixture(); record.invalidated_at = new Date(); + loan.evidences.push({ kind: 'agreement', governance_status: 'approved' }); + expect(() => assertEvidenceComplete(loan)).not.toThrow(); + delete loan.lender_acknowledgements; expect(() => assertEvidenceComplete(loan)).not.toThrow(); + }); + it.each([ + ['pending', ({ record }) => { record.decision = null; }], + ['returned', ({ record }) => { record.decision.action = 'return'; }], + ['already invalidated', ({ record }) => { record.invalidated_at = new Date(); }], + ['unchecked original', ({ record }) => { record.decision.original_checked = false; }], + ['another signer', ({ record }) => { record.decision.actor_id = 'someone-else'; }], + ['maker signs own request', ({ record }) => { record.proposed_by = 'signer'; }], + ['another loan', ({ record }) => { record.loan_ids = ['other-loan']; }], + ['unverified identity', ({ lender }) => { lender.identity_status = 'unverified'; }], + ['different association', ({ lender }) => { lender.employee_id = 'other-employee'; }], + ['identity version changed', ({ lender }) => { lender.identity_version++; }], + ['account version changed', ({ lender }) => { lender.account_version++; }], + ['lender archived', ({ lender }) => { lender.archived_at = new Date(); }], + ['lender name changed', ({ lender }) => { lender.name = '另一合成人'; }], + ['company changed', ({ lender }) => { lender.legal_entity_id = 'other-company'; }], + ['amount changed', ({ loan }) => { loan.original_principal = '101'; }], + ['balance date changed', ({ loan }) => { loan.balance_as_of = new Date('2026-01-02'); }], + ['original changed', ({ loan }) => { loan.evidences[0].file_sha256 = 'b'.repeat(64); }], + ])('does not consume a statement with %s', (_, change) => { + const f = fixture(); change(f); expect(validAcknowledgement(f.loan)).toBeNull(); refused(f.loan); + }); + it('balance confirmation, later repayments and additional accounting evidence leave the frozen declaration valid', () => { + const { loan, record } = fixture(); loan.version = 9; loan.confirmation_status = 'confirmed'; + loan.allocations = [{ amount: '30.00', reservation_state: 'settled' }]; + loan.evidences.push({ kind: 'accounting_confirmation', governance_status: 'approved', id: 'extra' }); + // PostgreSQL JSONB may return keys in another order. + loan.lender_acknowledgements[0].snapshot_json = Object.fromEntries(Object.entries(snapshot(loan)).reverse()); + expect(validAcknowledgement(loan)).toBe(record); expect(() => assertEvidenceComplete(loan)).not.toThrow(); + }); + it.each([ + ['no online history', null, false], ['current declaration', null, true], ['invalidated declaration', new Date('2026-01-03'), true], + ])('confirmed-balance renewal scope: %s', (_, invalidatedAt, withRecord) => { + const { loan, record } = fixture(); loan.confirmation_status = 'confirmed'; record.invalidated_at = invalidatedAt; + if (!withRecord) loan.lender_acknowledgements = []; + expect(acknowledgementRenewable(loan)).toBe(Boolean(withRecord && invalidatedAt)); + }); +}); diff --git a/server/tests/unit/workspaceSettings.navigation.test.js b/server/tests/unit/workspaceSettings.navigation.test.js index 2505e7d7..ca5197ad 100644 --- a/server/tests/unit/workspaceSettings.navigation.test.js +++ b/server/tests/unit/workspaceSettings.navigation.test.js @@ -128,6 +128,11 @@ describe("workspace small-team navigation defaults", () => { const mainLabels = navigation.sections.main.map((item) => item.label); expect(mainLabels).toEqual(["工作台", "报销", "采购", "项目", "资产", "请假记录"]); + expect(Object.values(navigation.sections).flat().find(item => item.key === "companyLoans")).toBeUndefined(); + expect(employee.permissionCodes).toContain('loan.acknowledge_own'); + expect(employee.permissionCodes).not.toContain('loan.manage'); + expect(mainLabels).not.toContain('付款'); + expect(mainLabels).not.toContain('应付账款'); expect(mainLabels).not.toContain("待我处理"); expect(mainLabels).not.toContain("票据"); expect(mainLabels).not.toContain("个人设置");