借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)
新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。 验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。 真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
这个提交包含在:
@@ -1124,6 +1124,8 @@ await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
|
||||
- Recovery snapshots bind material revisions, submission/fact/receipt identities, verification versions, independent participants and the current same-amount candidate set. Material writers (including OCR persistence) serialize on the batch; candidate formation also locks the lender. A local batch revision alone cannot detect another batch becoming a candidate. Validate at submission, approval and final cancellation, with physical document digest checks before release.
|
||||
- JSONB can reorder object keys. Compare snapshots using recursive key-canonicalized hashes, retaining array order, rather than hashing raw `JSON.stringify` results before and after storage. Keep snapshot payloads out of public response mapping. New evidence for the same bank transaction still requires new review.
|
||||
- Verify recovery through `RUN_ISOLATED_LOAN_DB_TEST=1 node scripts/qa/test-company-loan-db.cjs` with the locked BIFF8 Python environment: real HTTP/evidence/OCR, stale-round repair, no fallback after withdrawal, atomic rollback and deterministic interleavings with evidence, submissions, OCR and candidate formation. Existing browser checks do not prove the deferred S8 recovery UI; inline self-review is not independent review.
|
||||
- Manual no-submission is a separate terminal fact, never a bank termination claim: fixed documentary categories, complete reviewed export/submission inventory, two distinct independent reviews, material/candidate snapshot and physical digest checks at final release. An empty submission attestation is valid only for actual exports and no historical submission rows; withdrawn submissions do not prove non-submission.
|
||||
- Late-payment supplements and bank returns use an append-only ledger, not exportable payment batches. Include supplemental principal in both application balance calculations and SQL capacity checks. Their keyed transaction claims must exclude ordinary receipt claims and aliases in both directions under the shared receipt lock. Freeze causes close independently; partial returns and unresolved reservation conflicts keep their own causes active. The ordinary pre-payment gate must not block factual reconciliation.
|
||||
- ERP-specific API/adapters/jobs/enqueues are retired by `09-17-erp-exclusive-retirement`. Shared outbox/notification/WeCom and historical records remain. New ERP enqueue and direct dispatch reject; pending selection excludes `erp.*`; historical retry returns `integration.erp_retired` without mutating records. Never restore these retired consumers solely to satisfy old success-path tests.
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
## 2026-10-07 S4–S8 实施细化(沿已批准范围,尚待最终验收)
|
||||
|
||||
- Q6 使用独立的人工声明与复核记录,不混入银行终态事实。三类附件固定分类;查询及对账覆盖首次导出至登记日(公司时区),每个相关导出人均须有文件去向说明。完整空提交清单可以独立复核;撤回登记不能充当“未提交”。混合批次仍逐条验证已提交部分的银行终态。
|
||||
- 每次人工声明绑定材料修订、全部导出/提交/核验、付款快照及同额候选。两名不同且当前合格的复核人分别决定。失效声明可以追加取代,原银行终态不改;最终取消重验全部条件、物理文件摘要及双审,数据库另外约束释放配对。
|
||||
- 冻结按来源事件逐条记录;只在新申请、付款前复核及导出入口检查。关闭函数仅供对账业务事务调用,没有通用解冻接口。误报须经独立人员凭更正证据确认;仅不确定或证据不足不能关闭。
|
||||
- 晚到成功及退款到账以人工核对银行原件的方式登记(不是 OCR 通过)。输入按既有回单归一化规则核对成功状态、双方账号、币种、金额和流水号,原始人工输入加密存储;查询完整输入必须填写用途并记审计。已取消批次可以另存银行证据,但不重开原付款回单、付款项或导出。
|
||||
- 归属批准到入账之间再次出现同额候选,最终入账重验会拒绝。可用同一交易的新登记取代未入账的旧批准,再独立复核;取代时保持至少一条冻结。银行交易的唯一认领在最终入账事务中消费,复核仅做冲突检查,避免尚未入账的失效批准占死交易身份。已入账的银行事实不能被重提覆盖。
|
||||
- 晚到补记与追回使用独立银行事件、交易认领及逐借款补记表,不创建付款批次。交易认领和原核销/别名空间相互排斥;复核与入账同样校验密钥和交易身份。补记成功之后,新申请可用本金要扣掉补记结清额,数据库容量检查也包含这部分。
|
||||
- 未释放的原占用在补记事务中以被银行事实推翻为原因释放,保留原终态和取消信息;其他仅占用的申请须先处理,不能算作已付。已经付掉的部分按差额列待追回;整笔超额时也保留银行全额和零结清记录。追回只接受实际到账并经复核,不用冲抵;部分到账不解冻。
|
||||
- 已有待复核或已批准的晚到成功时,旧终态取消入口拒绝,必须走对账补记;数据库延迟约束同样保护这条边界。取代已产生占用冲突的未入账事件时,同事务先转移冲突冻结再关闭旧原因;新事件入账或凭更正证据确认误报时关闭对应派生原因,不能留下不可处理的旧冻结。
|
||||
- 已用于误报解冻的更正证据属于已消费的复核依据,服务及数据库均禁止再作废。更正记录、原始银行事件及关闭依据保留,不回写原结论。
|
||||
- 终态界面支持一次关联多份同指令处理中回单。复核卡明确展示所选提交、终态类型、证据、了结回单;Q6 展示逐份导出及三类证据的准确绑定,失败归属展示覆盖指令及核验附件。历史作废回单仅可通过实际事实绑定、必填用途的对账读取查看;权限、隔离策略、物理摘要、审计及 no-store 保留,普通作废回单下载仍被拒绝,不恢复其付款效力。
|
||||
- S8 沿用既有 React 对话框和样式,未引入新视觉方案。页面集中展示导出、提交、证据、人工声明、补证轮次、银行事件和冻结原因;原制单不得再次导入网银的提示不是技术保证。
|
||||
- S0(存量、合格复核人数、银行编号关系及官方材料)、真实回单验收仍未完成;本轮资金与界面代理已完成修复后只读静态复核,运行时结果见 implement。该复核不替代异模型或业务人员验收。本地实现/合成验证不代表可以部署或真实还款。
|
||||
|
||||
## 2026-10-07 补证后恢复方案(SR,第 1 版,用户已批准本地实施)
|
||||
|
||||
本方案先完成设计,随后用户明确回复“批准”,授权 SR-A、SR-B 本地实施与验证;SR-C 随 S8,提交、推送与部署未授权。S3 已本地提交 `14aee82d`;下文历史设计及实施记录保留其原时点。本方案补充 3.2 的释放流程,不把普通补证归入 S6 的晚到成功。
|
||||
|
||||
@@ -1,3 +1,38 @@
|
||||
## 2026-10-07 后续收口执行(用户同意执行)
|
||||
|
||||
按后续工作清单,先将已验证的 S4–S8 共 32 项改动本地提交,提交保留“真实回单待验、整体业务验收未通过”。随后补齐提交登记、清单复核、失败归属、晚到补记和追回的浏览器闭环,并准备发布前存量/迁移/回退检查材料。当前 inline 主会话直接实现和检查,不另派代理;此前两名代理的复核仅覆盖其当时差异。生产查询单独确认,人员与银行材料待外部答复;推送、部署和远端备份切换不在本次默认执行内。以下“32 项未提交”属于提交前验证时点。
|
||||
|
||||
- [ ] 本地提交已验证 S4–S8。
|
||||
- [ ] 补齐其余银行对账表单的电脑/手机闭环并修复发现的问题。
|
||||
- [ ] 在现有任务/恢复手册整理具体发布准备与未满足条件。
|
||||
- [ ] S0 生产只读核实及外部人员、银行材料确认(分别记录)。
|
||||
|
||||
## 2026-10-07 后续执行接续(用户要求已确定工作直接执行)
|
||||
|
||||
沿既有设计与 S4–S8 清单推进本地实施、针对性验证与主会话检查,不再逐阶段等待通知。先 S4,再按依赖推进 S5、S6、S7、S8;提交、推送、生产操作仍遵守单独授权。SR-A/B 已本地提交 `2e00d30f`。前阶段 inline 下未派独立代理;本轮工具已可用,按 AGENTS 对资金流程的要求另派两名只读代理检查资金与界面,修复后静态复核通过(同模型,非业务独立验收)。
|
||||
|
||||
- [x] S4 本地实施与合成验证:Q6 三类证据独立分类、全量材料快照、两名独立复核、人工终态及最终取消重验;覆盖混合导出(已提交部分沿 S3,未提交部分逐份举证)。
|
||||
- [x] S5–S8 本地实施、针对性回归及只读静态复核;整体业务验收仍受 S0 和真实样本限制。
|
||||
- S4 技术边界:人工声明覆盖未关联提交的导出,不可覆盖任何已登记(含撤回)的提交;查询及对账单覆盖首次导出至登记日(公司时区),所有导出人的文件去向说明齐全。快照变更后重新登记、重新双审,原终态不改。附件分类不允许事后互换,不进入回单 OCR;真实材料及人员可用性仍待 S0。
|
||||
|
||||
### 本轮阶段结果(本地验证通过,尚未提交)
|
||||
|
||||
- S4:新增 `loan_not_submitted_proposals/reviews`、三类固定证据分类、完整空清单复核、双人决定、人工终态、材料变更取代及最终取消重验。增量迁移 `20261008030000_loan_not_submitted`。
|
||||
- S5:新增逐原因冻结、付款前统一门禁;事实登记不冻结,没有通用管理员解冻接口。迁移 `20261008040000_loan_freeze_reasons`。
|
||||
- S6/S7:新增银行事实、跨核销空间交易认领、逐借款补记、实际追回;本金容量同时包含补记结清。迁移 `20261008050000_loan_bank_result_events`。当前原件人工录入/独立核对,不声称 OCR 或银行真实性自动验证。待审的同一交易可用新证据取代,先产生新冻结再关闭旧冻结;已复核银行事实的原件不得作废,物理文件损坏仍拒绝入账。
|
||||
- S8:详情页接入提交、清单、终态、失败归属、Q6、SR 恢复、晚到补记/追回;查询完整人工输入和原件要求用途与审计,电脑/手机完成 Q6、SR 恢复及多回单终态的完整弹窗流程;其他新增表单以 HTTP 与类型检查覆盖。沿用既有视觉组件。
|
||||
- 有效变异共 13/13 被测试捕获(6 个首轮、2 个金额边界、2 个最终入账守卫、3 个复核修复守卫)。包括三证类别、物理摘要、快照、冻结、交易身份、部分追回、占用冲突、超额拆分、最终候选、冻结转移、更正依据和旧取消旁路。部分变异仍被下游/数据库拒绝,测试捕获的是错误响应或错误原因,不能写成全部造成了错误释放。环境路径错误的首轮未计入;变异只在临时源码副本中进行,后续固定源码轮次摘要未变。
|
||||
- 最终隔离回归:81 个迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组通过,容器清理 PASS。包含整笔超额、逐原借款差额、跨流程交易复用、原审批人离职、审计回滚、候选变更重新复核及冻结/新申请/导出的确定性交错。固定前后端源码/构建副本的最后一轮再次得到相同通过结果,副本及自有容器已清理。浏览器覆盖原有 7 组及电脑/手机 Q6 双审、SR 补证、两份处理中回单终态释放(包括作废历史回单的复核下载)6 组。人工等待期在 HTTP 验证,浏览器合成配置设为 0,不等待现实三天。
|
||||
- 服务端首跑 4472 通过、8 失败、1 原有跳过(4481 总);8 个失败为报销回单旧测试替身缺少新增跨台账认领查询,补齐后相关两文件 150/150 通过,最后共享下载路径调整后再次 150/150 通过。合并覆盖 4480 项通过、1 项原有跳过,未将其描述为完整套件重新全跑通过。
|
||||
- 中间失败如实保留:浏览器精确标签、分页缓存等待、Q6 合成等待期设置及重新登录加载等待先后修正;运行器后来固定 client/dist 副本,避免与构建文件更换交错。失败轮次不计通过。首轮变异因临时 Python 路径错误停在制单生成,不计有效变异。
|
||||
- Prisma 校验、前端类型检查/构建、共享回单定向测试、UI 规则、错误码合同、敏感扫描、任务治理/流程合同及 git diff --check 通过。文档注册检查退出 0,保留原有 22 个未登记 docs 告警。
|
||||
|
||||
- 本轮只读复核修复:取代银行事件遗留冲突冻结;误报解冻依据可作废;终态界面只能选一份处理中回单;各复核卡缺准确材料绑定;已作废处理中回单无法供复核查看。分别补齐服务/数据库保护、历史证据读取及桌面手机回归。两名代理对修复后源码静态复核通过,最终运行时结果见上方;不把同模型审查当作外部业务验收。
|
||||
- 最终候选另外修正:晚到事实未处理时旧取消不能释放;归属候选在批准后变化时入账拒绝;唯一交易认领只在最终入账事务消费,允许未入账失效记录重新登记。
|
||||
- 所有测试只用合成数据和本次拥有的隔离容器。未查询生产、未提交推送部署;S0、真实样本和外部业务验收仍待完成,整体借款验收未通过。
|
||||
|
||||
复现入口沿用上面的隔离回归命令(具体命令见下方 SR 记录);新增 3 个迁移必须列入将来经过批准的预期迁移。本轮 32 项未提交,HEAD 为 `2e00d30f`;未推送、未部署、未查生产。S0 仍须核实存量、两名额外合格复核人、建行官方无结果材料和回单/指令编号对应。真实银行材料、银行事实的业务真实性以及异模型/业务独立验收不由合成数据证明。保持任务进行中,不归档。
|
||||
|
||||
## 2026-10-07 SR-A / SR-B 收尾与提交授权
|
||||
|
||||
用户明确批准“先收尾 SR-A/B,本地提交;S4 另批”。本次将下方 15 项实现、测试及文档作为一个本地提交收口,沿用已完成的运行时验证,收尾只更新记录并重验任务治理及差异格式。未推送、未部署、未查询生产。当前 inline 模式下独立复核仍待完成;界面留在 S8,真实回单待验,不能单独部署启用。整体任务保持进行中,不归档;以下“未提交”和 HEAD 描述为实施验证时点的记录。
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 公司借款台账与本金归还
|
||||
|
||||
## 2026-10-07 本地阶段状态
|
||||
|
||||
S4–S8 与 SR 界面已完成本地实现及合成回归:81 迁移、HTTP 95、浏览器 13、数据库约束 9 组通过,修复后的资金与界面只读静态复核通过(同模型)。S0、真实建行材料和业务独立验收仍未完成;不据此勾选整体真实还款验收或声明已部署。完整证据与限制见 implement.md 顶部。
|
||||
|
||||
## 2026-09-17 实施接续
|
||||
|
||||
用户明确要求借款尽快完成,沿本PRD与design既有个人出借人/CNY本金范围推进本地T1–T4,取代此前暂缓实施的优先级;不扩大为利息、备用金或通用付款。真实资料、余额和账户仍须正式录入核实,本地只用合成样例。风险与独立复核在交付/上线前保持未完成,不通过读取生产密钥或重跑旧恢复为本地开发解阻。
|
||||
@@ -45,6 +49,10 @@
|
||||
|
||||
付款成功与代账记账分开,记录凭证编号/记账确认及材料交接,不由OA自动生成会计分录。材料包括原借入凭证、协议/确认书、余额核对、审批及还款回单;本金不要求费用发票,不进入费用报销统计或费用预算。关键材料按适用访问权限留存,历史余额/分配不得无审计改写。
|
||||
|
||||
## 2026-10-07 S4–S8 本地接续
|
||||
|
||||
用户要求沿已确定工作直接执行。S4–S8 的本地实现和验证继续推进,阶段证据在 implement 顶部;AC9–AC19 不因后端或合成测试通过而自动勾为整体验收通过。新银行事件均标明人工核对,不伪称真实银行/OCR 已验;生产查询、推送、部署仍未执行。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] AC1:合成历史两笔借入可登记并确认余额;未确认/重复登记/无凭据不进入还款,历史录入不被统计成当天新增收款。
|
||||
|
||||
@@ -17,7 +17,7 @@ export type CompanyLoan = {
|
||||
outstanding: string; available: string; reserved: string; settled: string;
|
||||
evidences: LoanEvidence[]; accountingConfirmationRef: string | null;
|
||||
};
|
||||
export type LoanRepayment = {
|
||||
export type LoanRepayment = LoanReconciliation & {
|
||||
id: string; repaymentNo: string; lenderId: string; lenderName: string; amount: string; reason: string;
|
||||
bankResultState: string; paymentBatchId: string; status: string;
|
||||
accountingHandoffRef: string | null; accountingHandoffAt: string | null;
|
||||
@@ -61,3 +61,43 @@ export function uploadLoanEvidence(id: string, kind: string, file: File) {
|
||||
export function downloadLoanEvidence(id: string, evidenceId: string, reason: string, paymentBatchId?: string) {
|
||||
return apiRequestBlob(`/api/company-loans/${id}/evidences/${evidenceId}/download`, { method: "POST", body: JSON.stringify({ reason, paymentBatchId }) });
|
||||
}
|
||||
|
||||
export type LoanReconciliation = {
|
||||
failureAttributions?: Array<{ id: string; status: string; proposedBy: string; basisNote: string; exclusionNote: string; coveredSubmissionIds: string[]; failureScope: string; receiptAttachmentIds: string[] }>;
|
||||
bankSubmissions?: {
|
||||
exports: Array<{ id: string; sequence: number; exportedAt: string; exportedBy: string; customerSerials: string[] }>;
|
||||
legacy: { knownExportCount: number; unknownFacts: string[] } | null;
|
||||
submissions: Array<{ id: string; exportId: string | null; bankInstructionRef: string; status: string; submittedAt: string; registeredBy: string; note: string }>;
|
||||
attestation: { id: string; status: string; proposedBy: string; stale: boolean } | null;
|
||||
};
|
||||
terminal?: {
|
||||
evidences: Array<{ id: string; originalName: string; evidenceKind: string; voided: boolean }>;
|
||||
facts: Array<{ id: string; submissionId: string; status: string; proposedBy: string; kind: string; note: string; terminalStatus: string; evidenceId: string | null; receiptAttachmentId: string | null; resolvesAttachmentIds: string[] }>;
|
||||
};
|
||||
releaseRecovery?: {
|
||||
current: { id: string; sequence: number; status: string; stale: boolean } | null;
|
||||
history: Array<{ id: string; sequence: number; status: string; startedBy: string; reason: string }>;
|
||||
};
|
||||
notSubmitted?: { history: Array<{ id: string; sequence: number; status: string; proposedBy: string; stale: boolean;
|
||||
payload: { exportIds: string[]; queryEvidenceId: string; statementEvidenceId: string; exporterStatements: Array<{ exporterId: string; evidenceId: string }>; coverageFrom: string; coverageThrough: string; note: string }; reviews: Array<{ reviewedBy: string; decision: string; reason: string }> }> };
|
||||
freeze?: { frozen: boolean; reasons: Array<{ id: string; kind: string; status: string; paymentBatchId: string }> };
|
||||
bankResults?: { events: Array<{ id: string; kind: string; parentId: string | null; evidenceId: string; status: string; amount: string;
|
||||
proposedBy: string; reviewedBy: string | null; reviewNote: string | null; outstandingReturn: string | null;
|
||||
settlements: Array<{ loanId: string; bankAmount: string; settledAmount: string; receivableAmount: string }> }> };
|
||||
};
|
||||
export function uploadLoanBankEvidence(batchId: string, kind: string, file: File) {
|
||||
const body = new FormData(); body.append("file", file);
|
||||
return apiRequestData<{ id: string }>(`/api/payment-batches/${batchId}/loan-terminal-evidences?kind=${encodeURIComponent(kind)}`, { method: "POST", body });
|
||||
}
|
||||
export function downloadLoanBankEvidence(batchId: string, id: string, reason: string) {
|
||||
return apiRequestBlob(`/api/payment-batches/${batchId}/loan-terminal-evidences/${id}/file`, { headers: { "x-access-reason": encodeURIComponent(reason) } });
|
||||
}
|
||||
|
||||
export function readLoanBankResultDetails(batchId: string, id: string, reason: string) {
|
||||
return apiRequestData<{ fields: Array<{ Name: string; Value: string }>; attributionNote: string; exclusionNote: string }>(`/api/payment-batches/${batchId}/loan-bank-results/${id}/details`, { headers: { "x-access-reason": encodeURIComponent(reason) } });
|
||||
}
|
||||
|
||||
export function downloadLoanReconciliationReceipt(batchId: string, attachmentId: string, reason: string) {
|
||||
const query = new URLSearchParams({ purpose: "loan_reconciliation", reason });
|
||||
return apiRequestBlob(`/api/payment-batches/${batchId}/receipt-attachments/${attachmentId}/file?${query}`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export const ERROR_REASON_MESSAGES: Record<string, string> = {
|
||||
"loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。",
|
||||
"loan.lender_frozen": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。",
|
||||
"loan.not_submitted_invalid": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。",
|
||||
"loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。",
|
||||
"loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。",
|
||||
"loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。",
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
import { saveLoanAction, uploadLoanBankEvidence, downloadLoanBankEvidence, readLoanBankResultDetails, downloadLoanReconciliationReceipt, type LoanRepayment } from "../../api/companyLoans";
|
||||
import { type PaymentBatchReceiptAttachment } from "../../api/paymentBatches";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "../ui/dialog";
|
||||
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
|
||||
import { downloadBlob } from "../../lib/download";
|
||||
import { formatDisplayDate } from "../../lib/displayFormat";
|
||||
|
||||
const states: Record<string, string> = { proposed: "待复核", approved: "复核通过", rejected: "已驳回", superseded: "已取代", collecting: "补证中", withdrawn: "已撤回", consumed: "已释放", booked: "已登记入账", dismissed: "已确认误报" };
|
||||
const categories: Record<string, string> = { bank_terminal: "银行终态或更正书面证据", query_no_result: "指令查询无结果官方 PDF", account_statement: "同期付款账户对账单", exporter_statement: "导出人文件去向说明", late_success: "晚到成功回单", incoming_return: "出借人退款到账回单" };
|
||||
const actions: Record<string, string> = { downloadReceipt: "查看对应付款回单", details: "查看录入的银行事实", upload: "上传银行证据", submission: "登记银行提交", withdrawSubmission: "撤回提交登记", attest: "确认提交清单完整", reviewList: "独立复核提交清单", terminal: "登记银行终态事实", reviewFact: "独立复核终态事实", confirm: "确认银行未付款", failureAttribution: "登记失败回单归属", reviewFailure: "复核失败归属", q6: "登记人工未提交", reviewQ6: "特别复核人工未提交", recovery: "发起补证恢复", submitRecovery: "送审补证恢复", reviewRecovery: "复核补证恢复", withdrawRecovery: "撤回补证恢复", cancel: "重验依据并取消释放", event: "登记晚到成功", refund: "登记追回到账", reviewEvent: "独立复核银行事实", apply: "登记补记或追回", download: "查看银行证据", void: "作废银行证据" };
|
||||
|
||||
export function LoanBankReconciliation({ repayment: r, attachments }: { repayment: LoanRepayment; attachments: PaymentBatchReceiptAttachment[] }) {
|
||||
const { me, permissions } = useAuth(); const cache = useQueryClient();
|
||||
const finance = !!me?.roleCodes.some((role) => ["finance", "finance_mgr"].includes(role)) && permissions.includes("loan.manage");
|
||||
const reviewer = !!me?.roleCodes.some((role) => ["manager", "ceo"].includes(role)) && permissions.includes("payment.batch.review");
|
||||
const [details, setDetails] = useState<Awaited<ReturnType<typeof readLoanBankResultDetails>> | null>(null);
|
||||
const [action, setAction] = useState<string | null>(null); const [target, setTarget] = useState("");
|
||||
const [pendingIds, setPendingIds] = useState<string[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({}); const [file, setFile] = useState<File | null>(null);
|
||||
const value = (key: string) => values[key] ?? "";
|
||||
const set = (key: string, next: string) => setValues((old) => ({ ...old, [key]: next }));
|
||||
const base = `/payment-batches/${r.paymentBatchId}`;
|
||||
const exports = r.bankSubmissions?.exports ?? []; const submissions = r.bankSubmissions?.submissions ?? [];
|
||||
const evidences = r.terminal?.evidences ?? []; const current = r.releaseRecovery?.current;
|
||||
const q6 = r.notSubmitted?.history[0]; const list = r.bankSubmissions?.attestation;
|
||||
const unlinked = exports.filter((e) => !submissions.some((s) => s.status === "active" && s.exportId === e.id));
|
||||
const mutation = useMutation({ mutationFn: async () => {
|
||||
const note = value("note"); const decision = value("decision") || "approve";
|
||||
const reviewBody = { decision, reason: note };
|
||||
if (action === "details") setDetails(await readLoanBankResultDetails(r.paymentBatchId, target, note));
|
||||
if (action === "upload") { if (!file) throw new Error("请选择证据文件"); await uploadLoanBankEvidence(r.paymentBatchId, value("category") || "bank_terminal", file); }
|
||||
if (action === "download") { const result = await downloadLoanBankEvidence(r.paymentBatchId, target, note); downloadBlob(result.blob, result.filename ?? "银行证据"); }
|
||||
if (action === "downloadReceipt") { const result = await downloadLoanReconciliationReceipt(r.paymentBatchId, target, note); downloadBlob(result.blob, result.filename ?? "付款回单"); }
|
||||
if (action === "void") await saveLoanAction(`${base}/loan-terminal-evidences/${target}/void`, { reason: note });
|
||||
if (action === "submission") await saveLoanAction(`${base}/loan-bank-submissions`, { channel: value("export") ? "online_banking_import" : "manual_entry", ...(value("export") ? { exportId: value("export") } : {}), bankInstructionRef: value("reference"), submittedAt: new Date(value("date")).toISOString(), note });
|
||||
if (action === "withdrawSubmission") await saveLoanAction(`${base}/loan-bank-submissions/${target}/withdraw`, { reason: note });
|
||||
if (action === "attest") await saveLoanAction(`${base}/loan-submission-attestation`, { note });
|
||||
if (action === "reviewList") await saveLoanAction(`${base}/loan-submission-attestation/${target}/review`, reviewBody);
|
||||
if (action === "terminal") await saveLoanAction(`${base}/loan-terminal-facts`, { submissionId: value("submission"), kind: value("receipt") ? "failure_receipt" : "termination_evidence", ...(value("receipt") ? { receiptAttachmentId: value("receipt") } : { evidenceId: value("evidence"), terminalStatus: value("terminalStatus") || "failed" }), resolvesAttachmentIds: pendingIds, note });
|
||||
if (action === "reviewFact") await saveLoanAction(`${base}/loan-terminal-facts/${target}/review`, reviewBody);
|
||||
if (action === "confirm") await saveLoanAction(`${base}/loan-not-paid/review`, {});
|
||||
if (action === "failureAttribution") await saveLoanAction(`${base}/loan-receipt-attribution`, { kind: "failure", attachmentId: value("receipt"), ...(value("submission") ? { submissionId: value("submission") } : {}), basisKind: "bank_instruction_ref", basisNote: note, exclusionNote: value("exclusion") });
|
||||
if (action === "reviewFailure") await saveLoanAction(`${base}/loan-receipt-attribution/${target}/review`, reviewBody);
|
||||
if (action === "q6") await saveLoanAction(`${base}/loan-not-submitted`, { exportIds: unlinked.map((e) => e.id), queryEvidenceId: value("query"), statementEvidenceId: value("statement"), exporterStatements: [...new Set(unlinked.map((e) => e.exportedBy))].map((id) => ({ exporterId: id, evidenceId: value(`exporter:${id}`) })), coverageFrom: new Date(value("from")).toISOString(), coverageThrough: new Date(value("through")).toISOString(), note });
|
||||
if (action === "reviewQ6") await saveLoanAction(`${base}/loan-not-submitted/${target}/review`, reviewBody);
|
||||
if (action === "recovery") await saveLoanAction(`${base}/loan-release-recoveries`, { requestKey: crypto.randomUUID(), reason: note });
|
||||
if (action === "submitRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/submit`, {});
|
||||
if (action === "reviewRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/review`, reviewBody);
|
||||
if (action === "withdrawRecovery") await saveLoanAction(`${base}/loan-release-recoveries/${target}/withdraw`, { reason: note });
|
||||
if (action === "cancel") await saveLoanAction(`${base}/cancel`, { reason: note, ...(current ? { recoveryId: current.id, requestKey: crypto.randomUUID() } : {}) });
|
||||
if (action === "event" || action === "refund") await saveLoanAction(`${base}/loan-bank-results`, { kind: action === "refund" ? "return" : "late_success", ...(action === "refund" ? { parentId: target } : {}), evidenceId: value("evidence"), requestKey: crypto.randomUUID(), attributionNote: note, exclusionNote: value("exclusion"), fields: [{ Name: "付款账号", Value: value("payer") }, { Name: "收款账号", Value: value("payee") }, { Name: "金额", Value: value("amount") }, { Name: "币种", Value: "CNY" }, { Name: "交易状态", Value: "交易成功" }, { Name: "流水号", Value: value("reference") }] });
|
||||
if (action === "reviewEvent") await saveLoanAction(`${base}/loan-bank-results/${target}/review`, { ...reviewBody, ...(decision === "reject" && value("evidence") ? { correctionEvidenceId: value("evidence"), correctionKind: value("correction") || "misassociated" } : {}) });
|
||||
if (action === "apply") await saveLoanAction(`${base}/loan-bank-results/${target}/apply`, {});
|
||||
}, onSuccess: async () => { if (action === "details") return; setAction(null); await Promise.all(["payment-batches", "company-loans", "loan-repayments"].map((key) => cache.invalidateQueries({ queryKey: [key] }))); } });
|
||||
function open(name: string, id = "") { setAction(name); setTarget(id); setValues({}); setPendingIds([]); setDetails(null); setFile(null); mutation.reset(); }
|
||||
const button = (name: string, id = "") => <Button key={`${name}:${id}`} type="button" variant="outline" onClick={() => open(name, id)}>{actions[name]}</Button>;
|
||||
const input = (key: string, label: string, type = "text") => <label className="grid gap-2 text-sm" key={key}>{label}<Input required type={type} value={value(key)} onChange={(e) => set(key, e.target.value)} /></label>;
|
||||
const select = (key: string, label: string, choices: Array<[string, string]>, required = true) => <label className="grid gap-2 text-sm" key={key}>{label}<select aria-label={label} required={required} className="min-h-11 min-w-0 w-full rounded border px-3" value={value(key)} onChange={(e) => set(key, e.target.value)}><option value="">请选择{required ? "" : "(可不选)"}</option>{choices.map(([id, title]) => <option key={id} value={id}>{title}</option>)}</select></label>;
|
||||
const evidenceSelect = (key: string, label: string, kind: string, required = true) => select(key, label, evidences.filter((e) => !e.voided && e.evidenceKind === kind).map((e) => [e.id, e.originalName]), required);
|
||||
const exportLabel = (id: string | null) => id ? `第 ${exports.find((e) => e.id === id)?.sequence ?? "未知"} 次导出` : "系统外手工提交";
|
||||
const submissionLabel = (id: string) => submissions.find((s) => s.id === id)?.bankInstructionRef ?? "提交记录已不可用";
|
||||
const evidenceLink = (id: string) => <Button type="button" variant="outline" onClick={() => open("download", id)}>证据 {evidences.findIndex((e) => e.id === id) + 1}:{evidences.find((e) => e.id === id)?.originalName ?? "不可用"}</Button>;
|
||||
const receiptLink = (id: string) => <Button type="button" variant="outline" onClick={() => open("downloadReceipt", id)}>付款回单 {attachments.findIndex((a) => a.id === id) + 1}{attachments.find((a) => a.id === id)?.archivedAt ? "(已作废,仅核对历史事实)" : ""}</Button>;
|
||||
const receiptOptions: Array<[string, string]> = attachments.map((a, i) => [a.id, `回单 ${i + 1}${a.archivedAt ? "(已作废的历史事实)" : ""}`]);
|
||||
const terminal = ["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(r.bankResultState);
|
||||
return <section className="space-y-3 rounded-md border p-3" aria-label="银行结果对账">
|
||||
<h3 className="font-semibold">银行结果对账</h3>
|
||||
{r.freeze?.frozen ? <p role="status" className="text-sm text-amber-800">该出借人暂停新还款、复核和导出。仍可登记银行事实、对账和追回;所有冻结原因处理完成后才恢复付款。</p> : null}
|
||||
{r.freeze?.reasons.filter((x) => x.status === "active").map((x) => <p key={x.id} className="text-sm">待处理:{x.kind === "late_success" ? "晚到成功待确认或补记" : x.kind === "receivable" ? "超额付款待追回" : "其他申请占用冲突"}</p>)}
|
||||
{r.bankSubmissions?.legacy ? <p className="text-sm text-amber-800">历史导出明细待核实,不能凭汇总释放额度。</p> : null}
|
||||
<p className="text-sm">等待期只是内部谨慎期,不能证明银行未付款。线下核对不能直接解除系统占用。</p>
|
||||
{exports.map((e) => <p key={e.id} className="break-words text-sm">导出 {e.sequence} · {formatDisplayDate(e.exportedAt)} · 客户方流水号 {e.customerSerials.join("、")}</p>)}
|
||||
{submissions.map((s) => <div key={s.id} className="flex flex-wrap items-center gap-2 text-sm"><span>指令 {s.bankInstructionRef} · {exportLabel(s.exportId)} · {formatDisplayDate(s.submittedAt)} · {s.note} · {s.status === "active" ? "已登记" : "已撤回登记(不代表银行撤销)"}</span>{finance && s.status === "active" && r.status !== "cancelled" ? button("withdrawSubmission", s.id) : null}</div>)}
|
||||
{list ? <div className="space-y-2 text-sm"><p>提交清单:{states[list.status]}{list.stale ? ",已变化,须重新确认复核" : ""}</p>{reviewer && list.status === "proposed" && !list.stale && list.proposedBy !== me?.id ? button("reviewList", list.id) : null}</div> : null}
|
||||
<div className="flex flex-wrap gap-2">{finance && ["reviewed", "failed", "cancelled"].includes(r.status) ? button("upload") : null}{finance && r.status !== "cancelled" ? <>{button("submission")}{button("attest")}{button("terminal")}{button("failureAttribution")}</> : null}{reviewer && r.bankResultState === "unknown" ? button("confirm") : null}</div>
|
||||
{evidences.map((e, evidenceIndex) => <div key={e.id} className="flex flex-wrap items-center gap-2 text-sm"><span className="break-all">证据 {evidenceIndex + 1} · {categories[e.evidenceKind] ?? "银行证据"}:{e.originalName}{e.voided ? "(已作废)" : ""}</span>{button("download", e.id)}{finance && !e.voided && r.status !== "cancelled" ? button("void", e.id) : null}</div>)}
|
||||
{r.terminal?.facts.map((f, i) => <div key={f.id} className="flex flex-wrap items-center gap-2 text-sm"><span>终态事实 {i + 1}:{states[f.status]} · 指令 {submissionLabel(f.submissionId)} · {f.kind === "failure_receipt" ? "失败回单" : ({ failed: "已失败", revoked: "已撤销", rejected: "已拒绝" }[f.terminalStatus] ?? f.terminalStatus)} · {f.note}</span>{f.evidenceId ? evidenceLink(f.evidenceId) : null}{f.receiptAttachmentId ? receiptLink(f.receiptAttachmentId) : null}{f.resolvesAttachmentIds.length ? <span>了结处理中回单:</span> : null}{f.resolvesAttachmentIds.map((id) => <span key={id}>{receiptLink(id)}</span>)}{reviewer && f.status === "proposed" && f.proposedBy !== me?.id ? button("reviewFact", f.id) : null}</div>)}
|
||||
{r.failureAttributions?.map((a) => <div key={a.id} className="space-y-2 text-sm"><p>失败归属:{states[a.status]} · {a.basisNote}</p><p>{a.exclusionNote}</p><p>归属范围:{a.failureScope === "submission" ? "指定提交" : "本批次提交清单"};覆盖指令:{a.coveredSubmissionIds.map(submissionLabel).join("、")}</p>{a.receiptAttachmentIds.map((id) => <span key={id}>{receiptLink(id)}</span>)}{reviewer && a.status === "proposed" && a.proposedBy !== me?.id ? button("reviewFailure", a.id) : null}</div>)}
|
||||
{q6 ? <div className="space-y-2 text-sm"><p>人工确认未提交:{states[q6.status]} · {q6.payload.note} · 覆盖 {formatDisplayDate(q6.payload.coverageFrom)} 至 {formatDisplayDate(q6.payload.coverageThrough)} · 已有 {q6.reviews.filter((x) => x.decision === "approve").length} 人同意{q6.stale ? ";材料已变化,须重新登记及双人复核" : ""}</p><p>覆盖导出:{q6.payload.exportIds.map(exportLabel).join("、")}</p><div className="flex flex-wrap gap-2"><span>查询:</span>{evidenceLink(q6.payload.queryEvidenceId)}<span>对账单:</span>{evidenceLink(q6.payload.statementEvidenceId)}{q6.payload.exporterStatements.map((x, i) => <span key={x.exporterId}>导出人 {i + 1}({exports.filter((e) => e.exportedBy === x.exporterId && q6.payload.exportIds.includes(e.id)).map((e) => exportLabel(e.id)).join("、")}):{evidenceLink(x.evidenceId)}</span>)}</div>{reviewer && q6.status === "proposed" && !q6.stale && q6.proposedBy !== me?.id && !q6.reviews.some((x) => x.reviewedBy === me?.id) ? button("reviewQ6", q6.id) : null}</div> : null}
|
||||
{finance && unlinked.length > 0 && r.status !== "cancelled" && (!q6 || q6.stale || q6.status === "rejected") ? button("q6") : null}
|
||||
{current ? <div className="space-y-2 text-sm"><p>补证恢复第 {current.sequence} 轮:{states[current.status]}{current.stale ? ";依据已变化,须重新发起" : ""}</p><div className="flex flex-wrap gap-2">{finance && current.status === "collecting" ? button("submitRecovery", current.id) : null}{finance && ["collecting", "proposed"].includes(current.status) ? button("withdrawRecovery", current.id) : null}{reviewer && current.status === "proposed" && !current.stale && r.releaseRecovery?.history[0]?.startedBy !== me?.id ? button("reviewRecovery", current.id) : null}</div></div> : null}
|
||||
{finance && ["confirmed_failed", "confirmed_not_paid"].includes(r.bankResultState) && r.status !== "cancelled" && (!current || current.stale || ["withdrawn", "rejected"].includes(current.status)) ? button("recovery") : null}
|
||||
{finance && terminal && r.status !== "cancelled" && !r.bankResults?.events.some((e) => e.kind === "late_success" && ["proposed", "approved"].includes(e.status)) ? button("cancel") : null}
|
||||
{terminal ? <p className="text-sm text-amber-800">原制单文件不得再导入网银。系统不能使已下载文件失效;出现晚到成功应立即登记对账。</p> : null}
|
||||
{finance && terminal ? button("event") : null}
|
||||
{r.bankResults?.events.map((e, i) => <div key={e.id} className="space-y-2 rounded border p-3 text-sm"><p>{e.kind === "return" ? "追回到账" : "晚到成功"} {i + 1} · ¥{e.amount} · {states[e.status]}</p>{e.outstandingReturn ? <p>待追回 ¥{e.outstandingReturn}</p> : null}{e.settlements.map((s) => <p key={s.loanId}>银行已付 ¥{s.bankAmount};结清本金 ¥{s.settledAmount};超额 ¥{s.receivableAmount}</p>)}<div className="flex flex-wrap gap-2">{button("download", e.evidenceId)}{button("details", e.id)}{reviewer && e.status === "proposed" && e.proposedBy !== me?.id ? button("reviewEvent", e.id) : null}{finance && e.status === "approved" ? button("apply", e.id) : null}{finance && e.status === "booked" && Number(e.outstandingReturn) > 0 ? button("refund", e.id) : null}</div></div>)}
|
||||
<Dialog open={action !== null} onOpenChange={(next) => { if (!next && !mutation.isPending) setAction(null); }}><DialogContent className="max-h-[90dvh] overflow-y-auto"><DialogHeader><DialogTitle>{action ? actions[action] : "银行对账"}</DialogTitle><DialogDescription>核对原始银行材料和具体指令。独立复核人不能兼任相关经办或收款人;材料不足时保持占用或冻结。人工未提交须三类材料齐全并由两名独立人员复核。</DialogDescription></DialogHeader>
|
||||
<form className="grid gap-4" onSubmit={(e) => { e.preventDefault(); mutation.mutate(); }}>
|
||||
{details ? <div className="space-y-2 text-sm" aria-label="已授权查看的银行事实">{details.fields.map((f) => <p key={f.Name} className="break-all">{f.Name}:{f.Value}</p>)}<p>{details.attributionNote}</p><p>{details.exclusionNote}</p></div> : null}
|
||||
{mutation.error ? <p role="alert" className="text-sm text-red-700">{resolveErrorMessage(mutation.error)}</p> : null}
|
||||
{action === "upload" ? <>{select("category", "证据类别", Object.entries(categories))}<label className="grid gap-2 text-sm">证据文件<Input required type="file" accept="application/pdf,image/png,image/jpeg" onChange={(e) => setFile(e.target.files?.[0] ?? null)} /></label></> : null}
|
||||
{action === "submission" ? <>{select("export", "使用的导出文件(手工录入可不选)", exports.map((e) => [e.id, `第 ${e.sequence} 次导出`]), false)}{input("reference", "银行指令号")}{input("date", "实际提交时间", "datetime-local")}</> : null}
|
||||
{action === "terminal" || action === "failureAttribution" ? <>{select("submission", "对应银行提交", submissions.filter((s) => s.status === "active").map((s) => [s.id, s.bankInstructionRef]), action === "terminal")}{select("receipt", "失败回单(终态书面证据可不选)", receiptOptions, action === "failureAttribution")}{action === "terminal" ? <>{evidenceSelect("evidence", "银行终态书面证据", "bank_terminal", !value("receipt"))}{select("terminalStatus", "银行终态", [["failed", "已失败,不会再执行"], ["revoked", "已撤销,不会再执行"], ["rejected", "已拒绝,不会再执行"]], !value("receipt"))}<fieldset className="grid gap-2"><legend className="text-sm">了结的同指令处理中回单(可多选)</legend>{receiptOptions.map(([id, label]) => <label key={id} className="flex min-h-11 items-center gap-2 text-sm"><input type="checkbox" checked={pendingIds.includes(id)} onChange={(e) => setPendingIds((old) => e.target.checked ? [...old, id] : old.filter((x) => x !== id))} />{label}</label>)}</fieldset></> : input("exclusion", "排除其他候选批次的依据")}</> : null}
|
||||
{action === "q6" ? <>{evidenceSelect("query", "查询无结果官方 PDF", "query_no_result")}{evidenceSelect("statement", "同期对账单", "account_statement")}{[...new Set(unlinked.map((e) => e.exportedBy))].map((id, i) => evidenceSelect(`exporter:${id}`, `导出人 ${i + 1} 的文件去向说明`, "exporter_statement"))}{input("from", "查询与对账单覆盖开始", "datetime-local")}{input("through", "查询与对账单覆盖截至(须含登记日)", "datetime-local")}</> : null}
|
||||
{action?.startsWith("review") ? select("decision", "复核结论", [["approve", "依据充分,同意"], ["reject", "不同意/有依据确认误报"]]) : null}
|
||||
{action === "reviewEvent" && value("decision") === "reject" ? <>{evidenceSelect("evidence", "误关联或重复登记的更正证据(晚到成功必填)", "bank_terminal", false)}{select("correction", "关闭原因", [["misassociated", "误关联"], ["duplicate", "重复登记"]], false)}</> : null}
|
||||
{action === "event" || action === "refund" ? <>{evidenceSelect("evidence", "对应的成功银行回单", action === "refund" ? "incoming_return" : "late_success")}{input("payer", "回单付款账号")}{input("payee", "回单收款账号")}{input("amount", "回单人民币金额")}{input("reference", "回单流水号")}{input("exclusion", "为什么不属于其他候选批次或交易")}</> : null}
|
||||
{!['upload', 'submitRecovery', 'confirm', 'apply'].includes(action ?? '') ? <label className="grid gap-2 text-sm">依据与说明<textarea required minLength={10} maxLength={500} className="min-h-24 rounded border px-3 py-2" value={value("note")} onChange={(e) => set("note", e.target.value)} /></label> : null}
|
||||
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? "处理中…" : "确认"}</Button>
|
||||
</form>
|
||||
</DialogContent></Dialog>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LoanBankReconciliation } from "./LoanBankReconciliation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "../../auth/AuthContext";
|
||||
@@ -50,13 +51,17 @@ export function LoanPaymentDetails({ repayment, attachments }: { repayment: Loan
|
||||
const attribution = repayment.receiptAttribution;
|
||||
const openAttribution = attribution?.current && ["proposed", "approved"].includes(attribution.current.status) && !attribution.current.stale ? attribution.current : null;
|
||||
const payable = ["reviewed", "failed"].includes(repayment.status);
|
||||
const lateBankFact = repayment.bankResults?.events.some((e) => e.kind === "late_success" && ["proposed", "approved", "booked"].includes(e.status));
|
||||
const overturned = repayment.bankResults?.events.some((e) => e.kind === "late_success" && e.status === "booked");
|
||||
return <section className="card-std space-y-3 p-4" aria-label="本金还款分配">
|
||||
<h2 className="font-semibold">原借款分配与会计交接</h2>
|
||||
<LoanBankReconciliation repayment={repayment} attachments={attachments} />
|
||||
<p className="break-words text-sm">{repayment.reason}</p>
|
||||
{repayment.bankResultState === "unknown" ? <p className="text-sm text-amber-800">银行结果待查证;本金保持占用,不能取消释放或重复付款。</p> : null}
|
||||
{repayment.bankResultState === "confirmed_failed" ? <p className="text-sm text-amber-800">银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。</p> : null}
|
||||
{overturned ? <p className="text-sm text-amber-800">原未付款结论已被晚到成功推翻;实际付款、结清本金与待追回款见银行结果对账。</p> : null}
|
||||
{repayment.bankResultState === "confirmed_failed" && !lateBankFact ? <p className="text-sm text-amber-800">银行失败证据已由独立人员确认。请财务取消本申请释放额度,保留原记录。</p> : null}
|
||||
{attribution?.required && payable ? <div className="space-y-2 rounded-md border border-amber-300 p-3 text-sm" aria-label="回单归属确认">
|
||||
<p className="font-medium text-amber-800">回单归属待确认:同一出借人另有 {attribution.competitors.length} 笔同金额还款曾制单或上传回单({attribution.competitors.map((item) => item.batchNo).join("、")})。金额与账号一致不能证明回单属于本批次,确认前不能核销或释放额度。同一出借人同金额的每一期还款都需单独确认归属;存在同金额候选时,银行失败回单也不能释放额度,须线下对账。</p>
|
||||
<p className="font-medium text-amber-800">回单归属待确认:同一出借人另有 {attribution.competitors.length} 笔同金额还款曾制单或上传回单({attribution.competitors.map((item) => item.batchNo).join("、")})。金额与账号一致不能证明回单属于本批次,确认前不能核销或释放额度。同一出借人同金额的每一期还款都需单独确认归属;存在同金额候选时,失败回单须另外确认归属,并满足完整的银行终态释放条件。</p>
|
||||
{attribution.current ? <div className="space-y-1">
|
||||
<p>归属确认:{attributionStatusLabels[attribution.current.status]}{attribution.current.stale ? "(回单或候选批次已变化,须重新登记并复核)" : ""}</p>
|
||||
<p className="break-words">依据:{attributionBasisLabels[attribution.current.basisKind]};{attribution.current.basisNote}</p>
|
||||
|
||||
@@ -395,3 +395,19 @@ ERP专属实现与后台同步已从本地候选移除,历史ERP数据保留
|
||||
pause/resume保留首次生效时刻、全部已发号码和高水位;暂停仅影响以后创建对象,已归属v2的采购草稿仍按v2首次提交取号。策略变更与既有审计同事务,启停等待已持策略锁的业务事务结束。回退代码必须仍兼容两类号,不删除新增表/版本字段、不减计数,也不重新导出旧批次为新编号。
|
||||
|
||||
本地验证命令:`cd server && npm run prisma:validate && npm test`;隔离PG为 `RUN_ISOLATED_NUMBERING_DB_TEST=1 node scripts/qa/test-business-numbering-db.cjs`,使用本机已备好的PostgreSQL镜像和合成运行环境,禁外联/宿主端口,包含合成借款HTTP整链及银行文件测试。需沿用已有受控Python运行环境;不得因缺依赖下载或读取真实配置。本次新证据在 `artifacts/qa-layout/business-numbering-20260918`,旧失败与冻结材料保留;仍须另行完成原G20完整门禁、独立复核及授权发布后的公网验收。
|
||||
|
||||
|
||||
### 借款银行结果对账与恢复(09-17,本地待验收)
|
||||
|
||||
本节描述待验收的本地流程,不是生产已启用的操作指引。启用前须完成 S0 存量核实、合格复核人数及银行官方材料确认、真实回单验证和独立复核;不得直接发布当前 main。
|
||||
|
||||
- 导出与银行提交分别登记。提交清单须确认完整并经独立复核;新增、撤回登记或重新导出都会使旧确认失效。
|
||||
- 银行终态逐提交举证;不能用一条失败推定其他提交结束。成功事实不能靠作废附件消除,处理中回单仅可被同指令终态了结。最终取消时再次检查依据与等待期。
|
||||
- 仅导出未提交的人工例外须查询无结果官方 PDF、同期账户对账单、相关导出人的文件去向说明齐全,完整清单已复核且等待期满;另由两名独立合格人员复核。材料或人员不足继续占用,不能用线下结论直接解除。
|
||||
- 已确认失败/银行未付款,但依据失效且占用未释放:另发起补证恢复,补齐逐次终态、送审、独立复核,再取消。撤回或驳回不恢复旧释放依据;材料变化必须新轮次复核。原终态始终保留。
|
||||
- 晚到成功:另上传原件、人工录入并说明归属,一经登记即冻结后续付款;独立复核后补记,原批次不能再次导出。占用冲突须先处理;只有实际重复付出的差额列待追回。
|
||||
- 超额追回:实际退款到账回单、独立复核、登记入账;部分到账只减待追回额,全额到账后关闭相应原因。其他未关闭原因仍阻止付款。误报须独立复核更正证据后关闭,不删除原事件;已用于解冻的更正依据不能再作废。
|
||||
- 终态复核可一次核对多份同指令处理中回单。已作废的回单只通过对账卡片的历史证据入口查看,必须填写用途并记审计,不恢复付款效力。登记晚到成功后,原失败取消入口暂停释放;更换未入账记录会接续已有冲突冻结,不能用换记录解除冻结。
|
||||
- 不得再将原下载制单导入网银;OA 不能令已下载文件失效。发现新的银行事实,应登记对账事件,不改原终态或直接改数据库。
|
||||
|
||||
合成验证使用 `RUN_ISOLATED_LOAN_DB_TEST=1 RUN_ISOLATED_LOAN_BROWSER=1 node scripts/qa/test-company-loan-db.cjs`;BIFF8 依赖按锁定 requirements 安装并由 `PAYMENT_ORDER_PYTHON_BIN` 指定。容器无网络、无持久卷,只清理本次拥有的资源。完整结果及尚未覆盖项以该任务 implement.md 为准。
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
module.exports = async function bankResultsFixture(h) {
|
||||
const { prisma, pool, call, app, req, finance, manager, independent, verifiedLender, confirmedLoan, repay,
|
||||
exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid } = h;
|
||||
const { PDFDocument } = req('pdf-lib');
|
||||
const config = h.config;
|
||||
const { decryptStringOrThrow } = req('./src/services/crypto');
|
||||
async function upload(batchId, kind) {
|
||||
const doc = await PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]);
|
||||
const boundary = `synthetic-${randomUUID()}`;
|
||||
const res = await app.inject({ method: 'POST', url: `/api/payment-batches/${batchId}/loan-terminal-evidences?kind=${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.from(await doc.save()), Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
assert.equal(res.statusCode, 200); return res.json().data.id;
|
||||
}
|
||||
async function setup(cancelled = true) {
|
||||
const lender = await verifiedLender(`合成晚到${randomUUID().slice(0, 6)}`, null, '5');
|
||||
const loan = await confirmedLoan(lender.id, '100.00', '合成晚到借款');
|
||||
const batch = await repay(lender.id, loan.id, '80.00');
|
||||
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id);
|
||||
const exp = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: batch.id } });
|
||||
const sub = await register(batch.id, `LATE-${randomUUID()}`, exp.id); await attest(batch.id);
|
||||
const ev = await uploadEvidence(batch.id);
|
||||
const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: ev });
|
||||
await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id);
|
||||
if (cancelled) await call(finance, 'POST', `/payment-batches/${batch.id}/cancel`, { reason: '合成原终态取消' });
|
||||
const saved = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: batch.id } });
|
||||
return { lender, loan, batch, payer: decryptStringOrThrow(config, saved.payment_account_snapshot_json.accountNumberEncrypted) };
|
||||
}
|
||||
function fields(s, amount = '80.00', incoming = false, ref = `BANK-${randomUUID()}`) {
|
||||
return [{ Name: '付款账号', Value: incoming ? s.lender.bankAccount : s.payer }, { Name: '收款账号', Value: incoming ? s.payer : s.lender.bankAccount },
|
||||
{ Name: '金额', Value: amount }, { Name: '币种', Value: 'CNY' }, { Name: '交易状态', Value: '交易成功' }, { Name: '流水号', Value: ref }];
|
||||
}
|
||||
async function propose(s, kind = 'late_success', parentId, amount, ref, status = 200) {
|
||||
return call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results`, { kind, ...(parentId ? { parentId } : {}),
|
||||
evidenceId: await upload(s.batch.id, kind === 'return' ? 'incoming_return' : 'late_success'), requestKey: randomUUID(),
|
||||
fields: fields(s, amount, kind === 'return', ref), attributionNote: '合成银行附言唯一定位原批次的付款事实', exclusionNote: '合成银行查询材料逐一排除其他相同金额候选' }, status);
|
||||
}
|
||||
const review = (s, e, who = independent, body = {}, status = 200) => call(who, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results/${e.id}/review`, { decision: 'approve', reason: '合成独立核对流水归属及银行证据', ...body }, status);
|
||||
const apply = (s, e, status = 200) => call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-bank-results/${e.id}/apply`, {}, status);
|
||||
const balances = (s) => call(finance, 'GET', `/company-loans/${s.loan.id}`);
|
||||
const checks = [];
|
||||
for (const cancelled of [true, false]) {
|
||||
const s = await setup(cancelled); const count = await prisma.paymentBatch.count();
|
||||
const e = await propose(s);
|
||||
if (!cancelled) await call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`, { reason: '合成不得用旧结论释放' }, 409, 'loan.bank_result_reconcile_required');
|
||||
await review(s, e, finance, {}, 403); await review(s, e);
|
||||
await apply(s, e); await apply(s, e);
|
||||
assert.equal((await balances(s)).outstanding, '20.00'); assert.equal((await balances(s)).reserved, '0.00');
|
||||
assert.equal(await prisma.paymentBatch.count(), count);
|
||||
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.batch.loanRepaymentId } })).bank_result_state, 'confirmed_not_paid');
|
||||
const record = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: e.id } });
|
||||
assert.equal(record.bank_amount.toFixed(2), '80.00'); assert.equal(record.settled_amount.toFixed(2), '80.00');
|
||||
await assert.rejects(prisma.loanSupplementalSettlement.update({ where: { id: record.id }, data: { settled_amount: '1.00' } }));
|
||||
}
|
||||
checks.push('s6_late_success_cancelled_or_still_reserved_books_once_without_new_payment_batch');
|
||||
{
|
||||
const s = await setup(); const conflict = await repay(s.lender.id, s.loan.id, '70.00');
|
||||
const e = await propose(s); await review(s, e); assert.equal((await apply(s, e)).conflict, true);
|
||||
assert.equal((await balances(s)).outstanding, '100.00');
|
||||
await call(manager, 'POST', `/payment-batches/${conflict.id}/review`, {}, 409, 'loan.lender_frozen');
|
||||
await call(finance, 'POST', `/payment-batches/${conflict.id}/cancel`, { reason: '合成清除未付款占用' });
|
||||
await apply(s, e); assert.equal((await balances(s)).outstanding, '20.00');
|
||||
}
|
||||
checks.push('s6_reserved_conflict_is_not_overpayment_and_blocks_until_resolved');
|
||||
{
|
||||
const s = await setup();
|
||||
// Another real payment uses the existing HTTP/OCR settlement path.
|
||||
const paid = await repay(s.lender.id, s.loan.id, '60.00');
|
||||
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
|
||||
await h.receipt(paid.id, '6000', `ALREADY-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
|
||||
const e = await propose(s); await review(s, e); await apply(s, e);
|
||||
const row = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: e.id } });
|
||||
assert.equal(row.settled_amount.toFixed(2), '40.00'); assert.equal(row.receivable_amount.toFixed(2), '40.00');
|
||||
assert.equal((await balances(s)).outstanding, '0.00');
|
||||
await propose(s, 'return', e.id, '0.00', undefined, 409);
|
||||
const ret = await propose(s, 'return', e.id, '15.00'); await review(s, ret); await apply(s, ret);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
||||
const rest = await propose(s, 'return', e.id, '25.00'); await review(s, rest); await apply(s, rest);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
|
||||
assert.equal((await balances(s)).outstanding, '0.00');
|
||||
}
|
||||
checks.push('s6_s7_overpayment_is_difference_partial_return_keeps_frozen_full_return_closes');
|
||||
{
|
||||
const s = await setup(); const e = await propose(s);
|
||||
await review(s, e, independent, { decision: 'reject' }, 409);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
||||
const correctionEvidenceId = await upload(s.batch.id, 'bank_terminal');
|
||||
await review(s, e, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId });
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
|
||||
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event_id: e.id } }), 0);
|
||||
assert.equal((await balances(s)).outstanding, '100.00');
|
||||
}
|
||||
checks.push('s5_false_alarm_requires_evidenced_independent_dismissal_uncertainty_stays_frozen');
|
||||
console.log(JSON.stringify({ stage: "s6_s7", status: "PASS", checks }));
|
||||
{
|
||||
const s = await setup();
|
||||
const paid = await repay(s.lender.id, s.loan.id, '100.00');
|
||||
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
|
||||
const paidRef = `FULL-${randomUUID()}`;
|
||||
await h.receipt(paid.id, '10000', paidRef, { payeeAccount: s.lender.bankAccount });
|
||||
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
|
||||
const event = await propose(s); await review(s, event); await apply(s, event);
|
||||
const row = await prisma.loanSupplementalSettlement.findFirst({ where: { event_id: event.id } });
|
||||
assert.equal(row.settled_amount.toFixed(2), '0.00'); assert.equal(row.receivable_amount.toFixed(2), '80.00');
|
||||
assert.equal(row.bank_amount.toFixed(2), '80.00');
|
||||
// Same transaction as an ordinary executed payment must be rejected.
|
||||
const reused = await propose(s, 'late_success', undefined, '80.00', paidRef);
|
||||
await review(s, reused, independent, {}, 409);
|
||||
// The review-only correction exit does not erase the existing receivable cause.
|
||||
const correctionEvidenceId = await upload(s.batch.id, 'bank_terminal');
|
||||
await review(s, reused, independent, { decision: 'reject', correctionKind: 'duplicate', correctionEvidenceId });
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
||||
}
|
||||
checks.push('s6_full_excess_retains_bank_amount_and_shared_transaction_identity_blocks_reuse');
|
||||
{
|
||||
const s = await setup(); const event = await propose(s); await review(s, event);
|
||||
// Late factual payment must use original account snapshots even after legal
|
||||
// master-data changes, and a departed original approver cannot strand it.
|
||||
await prisma.employee.update({ where: { id: manager.id }, data: { status: 'archived' } });
|
||||
try { await apply(s, event); } finally { await prisma.employee.update({ where: { id: manager.id }, data: { status: 'active' } }); }
|
||||
assert.equal((await balances(s)).outstanding, '20.00');
|
||||
}
|
||||
checks.push('s6_original_approver_departure_does_not_block_bank_fact_booking');
|
||||
{
|
||||
const s = await setup(); const event = await propose(s); await review(s, event);
|
||||
await pool.query(`CREATE FUNCTION synthetic_bank_result_audit_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
|
||||
IF NEW.action='LOAN_LATE_SUCCESS_BOOKED' THEN RAISE EXCEPTION 'synthetic late audit failure'; END IF; RETURN NEW; END $$`);
|
||||
await pool.query('CREATE TRIGGER synthetic_bank_result_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION synthetic_bank_result_audit_failure()');
|
||||
try { await apply(s, event, 500); }
|
||||
finally { await pool.query('DROP TRIGGER synthetic_bank_result_audit_failure ON audit_logs'); await pool.query('DROP FUNCTION synthetic_bank_result_audit_failure()'); }
|
||||
assert.equal(await prisma.loanSupplementalSettlement.count({ where: { event_id: event.id } }), 0);
|
||||
assert.equal((await balances(s)).outstanding, '100.00');
|
||||
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: event.id } })).status, 'approved');
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
||||
await apply(s, event);
|
||||
}
|
||||
checks.push('s6_bookkeeping_audit_failure_rolls_back_amounts_status_and_freezes');
|
||||
const { Pool } = req('pg'); const lockPool = new Pool({ ...pool.options, max: 3 });
|
||||
async function waiters(n) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const result = await lockPool.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type='Lock' AND datname=current_database()");
|
||||
if (result.rows[0].n >= n) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error('bank result requests did not queue on lender');
|
||||
}
|
||||
try {
|
||||
for (const freezeFirst of [false, true]) {
|
||||
const s = await setup(); const next = await repay(s.lender.id, s.loan.id, '10.00');
|
||||
await call(manager, 'POST', `/payment-batches/${next.id}/review`, {});
|
||||
const evidenceId = await upload(s.batch.id, 'late_success');
|
||||
const holder = await lockPool.connect();
|
||||
const freeze = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-results`, headers: finance.headers,
|
||||
payload: { kind: 'late_success', evidenceId, requestKey: randomUUID(), fields: fields(s), attributionNote: '合成确定性交错银行归属说明', exclusionNote: '合成确定性交错排除其他付款批次' } });
|
||||
const exporting = () => app.inject({ method: 'POST', url: `/api/payment-batches/${next.id}/payment-order`, headers: finance.headers, payload: {} });
|
||||
let first, second;
|
||||
try {
|
||||
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
|
||||
first = freezeFirst ? freeze() : exporting(); await waiters(1);
|
||||
second = freezeFirst ? exporting() : freeze(); await waiters(2);
|
||||
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
|
||||
} finally { await holder.query('ROLLBACK'); holder.release(); }
|
||||
assert.equal(first.statusCode, 200); assert.equal(second.statusCode, freezeFirst ? 409 : 200);
|
||||
assert.equal(await prisma.paymentOrderExport.count({ where: { payment_batch_id: next.id } }), freezeFirst ? 0 : 1);
|
||||
}
|
||||
for (const bookFirst of [false, true]) {
|
||||
const s = await setup(); const event = await propose(s); await review(s, event);
|
||||
const holder = await lockPool.connect();
|
||||
const book = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-bank-results/${event.id}/apply`, headers: finance.headers, payload: {} });
|
||||
const request = () => app.inject({ method: 'POST', url: '/api/loan-repayments', headers: finance.headers,
|
||||
payload: { lenderId: s.lender.id, reason: '合成并发超额申请', requestKey: randomUUID(), allocations: [{ loanId: s.loan.id, amount: '30.00' }] } });
|
||||
let first, second;
|
||||
try {
|
||||
await holder.query('BEGIN'); await holder.query('SELECT id FROM loan_lenders WHERE id=$1 FOR UPDATE', [s.lender.id]);
|
||||
first = bookFirst ? book() : request(); await waiters(1);
|
||||
second = bookFirst ? request() : book(); await waiters(2);
|
||||
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
|
||||
} finally { await holder.query('ROLLBACK'); holder.release(); }
|
||||
assert.equal(first.statusCode, bookFirst ? 200 : 409); assert.equal(second.statusCode, bookFirst ? 409 : 200);
|
||||
assert.equal((await balances(s)).outstanding, '20.00'); assert.equal((await balances(s)).reserved, '0.00');
|
||||
}
|
||||
} finally { await lockPool.end(); }
|
||||
checks.push('s5_s6_freeze_vs_export_and_supplement_vs_new_request_deterministic_both_orders');
|
||||
{
|
||||
const s = await setup(false); const ref = `REPLACE-${randomUUID()}`;
|
||||
const old = await propose(s, 'late_success', undefined, '80.00', ref);
|
||||
const oldRow = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } });
|
||||
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${oldRow.evidence_id}/void`, { reason: '合成待审材料换版' });
|
||||
await review(s, old, independent, {}, 409);
|
||||
const fresh = await propose(s, 'late_success', undefined, '80.00', ref);
|
||||
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } })).status, 'superseded');
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 1);
|
||||
await review(s, fresh);
|
||||
const current = await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: fresh.id } });
|
||||
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${current.evidence_id}/void`, { reason: '合成已审事实不可作废' }, 409, 'loan.bank_result_reconcile_required');
|
||||
await apply(s, fresh);
|
||||
}
|
||||
checks.push('s6_pending_evidence_replaced_without_unfreeze_reviewed_bank_fact_evidence_retained');
|
||||
{
|
||||
const s = await setup(); const otherLoan = await confirmedLoan(s.lender.id, '100.00', '合成多借款补记第二笔');
|
||||
const combined = (await call(finance, 'POST', '/loan-repayments', { lenderId: s.lender.id, reason: '合成跨原借款逐笔补记', requestKey: randomUUID(),
|
||||
allocations: [{ loanId: s.loan.id, amount: '20.00' }, { loanId: otherLoan.id, amount: '40.00' }] })).item;
|
||||
await call(manager, 'POST', `/payment-batches/${combined.id}/review`, {}); await exportOrder(combined.id);
|
||||
const exp = await prisma.paymentOrderExport.findFirst({ where: { payment_batch_id: combined.id } });
|
||||
const submission = await register(combined.id, `COMBINED-${randomUUID()}`, exp.id); await attest(combined.id);
|
||||
const evidenceId = await uploadEvidence(combined.id);
|
||||
const fact = await proposeFact(combined.id, { submissionId: submission.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
|
||||
await reviewFact(combined.id, fact.id); await confirmNotPaid(combined.id);
|
||||
await call(finance, 'POST', `/payment-batches/${combined.id}/cancel`, { reason: '合成多借款原结论取消' });
|
||||
for (const [loanId, amount] of [[s.loan.id, '90.00'], [otherLoan.id, '70.00']]) {
|
||||
const paid = await repay(s.lender.id, loanId, amount);
|
||||
await call(manager, 'POST', `/payment-batches/${paid.id}/review`, {}); await exportOrder(paid.id);
|
||||
await h.receipt(paid.id, String(Number(amount) * 100), `MULTI-PAID-${randomUUID()}`, { payeeAccount: s.lender.bankAccount });
|
||||
await call(finance, 'POST', `/payment-batches/${paid.id}/execute`, {});
|
||||
}
|
||||
const combinedSource = { ...s, batch: combined }; const event = await propose(combinedSource, 'late_success', undefined, '60.00');
|
||||
await review(combinedSource, event); await apply(combinedSource, event);
|
||||
const rows = await prisma.loanSupplementalSettlement.findMany({ where: { event_id: event.id } });
|
||||
assert.equal(rows.length, 2);
|
||||
const a = rows.find((row) => row.loan_id === s.loan.id); const b = rows.find((row) => row.loan_id === otherLoan.id);
|
||||
assert.deepEqual([a.settled_amount.toFixed(2), a.receivable_amount.toFixed(2)], ['10.00', '10.00']);
|
||||
assert.deepEqual([b.settled_amount.toFixed(2), b.receivable_amount.toFixed(2)], ['30.00', '10.00']);
|
||||
assert.equal((await balances(s)).outstanding, '0.00');
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${otherLoan.id}`)).outstanding, '0.00');
|
||||
}
|
||||
checks.push('s6_multiple_original_loans_settle_each_difference_without_cross_loan_offset');
|
||||
{
|
||||
const s = await setup(); const competitor = await repay(s.lender.id, s.loan.id, '80.00');
|
||||
await call(manager, 'POST', `/payment-batches/${competitor.id}/review`, {});
|
||||
const ref = `STALE-APPROVED-${randomUUID()}`;
|
||||
const old = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, old);
|
||||
const submitted = await register(competitor.id, `NEW-CANDIDATE-${randomUUID()}`, null);
|
||||
await apply(s, old, 409);
|
||||
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: old.id } }), 0, 'review does not consume a transaction');
|
||||
await attest(competitor.id); const evidenceId = await uploadEvidence(competitor.id);
|
||||
const fact = await proposeFact(competitor.id, { submissionId: submitted.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
|
||||
await reviewFact(competitor.id, fact.id); await confirmNotPaid(competitor.id);
|
||||
await call(finance, 'POST', `/payment-batches/${competitor.id}/cancel`, { reason: '合成其他候选已确认终止' });
|
||||
const fresh = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, fresh); await apply(s, fresh);
|
||||
assert.equal((await prisma.loanBankResultEvent.findUniqueOrThrow({ where: { id: old.id } })).status, 'superseded');
|
||||
assert.equal(await prisma.loanBankEventClaim.count({ where: { event_id: fresh.id } }), 1);
|
||||
assert.equal((await balances(s)).outstanding, '20.00');
|
||||
}
|
||||
checks.push('s6_approved_attribution_stales_on_new_candidate_reregistration_rechecks_then_claims_once');
|
||||
for (const dismiss of [false, true]) {
|
||||
const s = await setup(); const conflict = await repay(s.lender.id, s.loan.id, '70.00');
|
||||
const ref = `REPLACE-CONFLICT-${randomUUID()}`;
|
||||
const old = await propose(s, 'late_success', undefined, '80.00', ref); await review(s, old);
|
||||
assert.equal((await apply(s, old)).conflict, true);
|
||||
const fresh = await propose(s, 'late_success', undefined, '80.00', ref);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { source_key: old.id, status: 'active' } }), 0);
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { source_key: fresh.id, status: 'active' } }), 2);
|
||||
if (dismiss) {
|
||||
await review(s, fresh, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: await upload(s.batch.id, 'bank_terminal') });
|
||||
assert.equal((await balances(s)).outstanding, '100.00');
|
||||
} else {
|
||||
await review(s, fresh);
|
||||
await call(finance, 'POST', `/payment-batches/${conflict.id}/cancel`, { reason: '合成解除其他申请占用' });
|
||||
await apply(s, fresh); assert.equal((await balances(s)).outstanding, '20.00');
|
||||
}
|
||||
assert.equal(await prisma.loanFreezeReason.count({ where: { lender_id: s.lender.id, status: 'active' } }), 0);
|
||||
}
|
||||
checks.push('s6_replacement_transfers_conflict_freeze_and_booking_or_evidenced_dismissal_closes_it');
|
||||
{
|
||||
const s = await setup(false); const event = await propose(s);
|
||||
const correction = await upload(s.batch.id, 'bank_terminal');
|
||||
await review(s, event, independent, { decision: 'reject', correctionKind: 'misassociated', correctionEvidenceId: correction });
|
||||
await call(finance, 'POST', `/payment-batches/${s.batch.id}/loan-terminal-evidences/${correction}/void`, { reason: '合成不得撤销已消费的误报更正依据' }, 409, 'loan.bank_result_reconcile_required');
|
||||
await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: correction }, data: { voided_at: new Date(), voided_by: finance.id, void_reason: '合成数据库旁路拒绝' } }));
|
||||
assert.equal((await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: correction } })).voided_at, null);
|
||||
assert.equal((await balances(s)).reserved, '80.00');
|
||||
}
|
||||
checks.push('s5_consumed_false_alarm_correction_cannot_be_voided_in_service_or_database');
|
||||
return checks;
|
||||
};
|
||||
@@ -6,11 +6,25 @@ const root = path.resolve(__dirname, '../..');
|
||||
const req = createRequire(path.join(root, 'client/package.json'));
|
||||
const { chromium, expect } = req('@playwright/test');
|
||||
|
||||
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi }) {
|
||||
module.exports = async function runBrowser({ app, finance, manager, ordinary, password, company, bank, prisma, attributionUi, reconciliationUi }) {
|
||||
const output = mkdtempSync(path.join(root, 'artifacts/qa-layout/payment-feedback-loan-slim-20260917/browser-'));
|
||||
const origin = await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const browser = await chromium.launch({ headless: true, args: ['--disable-background-networking'] });
|
||||
const checks = [];
|
||||
async function findOnPages(page, locator, endpoint) {
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
|
||||
if (await locator.count()) return;
|
||||
const next = page.getByRole('button', { name: '下一页', exact: true });
|
||||
await expect(next).toBeEnabled();
|
||||
const currentLabel = await page.getByText(/^第 \d+ 页$/).first().innerText();
|
||||
await next.click();
|
||||
await expect(page.getByText(currentLabel, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('status').filter({ hasText: '加载中…' })).toHaveCount(0);
|
||||
}
|
||||
throw new Error('synthetic record not found after pagination');
|
||||
}
|
||||
|
||||
try {
|
||||
for (const mobile of [false, true]) {
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
@@ -36,6 +50,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await page.getByRole('tab', { name: '出借人', exact: true }).click();
|
||||
const lenderCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) });
|
||||
await findOnPages(page, lenderCard, 'loan-lenders');
|
||||
await lenderCard.getByRole('button', { name: '核实身份关联' }).click();
|
||||
dialog = page.getByRole('dialog');
|
||||
await dialog.getByLabel('出借人对应身份').selectOption('external');
|
||||
@@ -58,6 +73,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
await page.getByRole('tab', { name: '借款台账', exact: true }).click();
|
||||
const loan = await prisma.companyLoan.findFirstOrThrow({ where: { origin_reference: reference } });
|
||||
const card = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${loan.loan_no}`, exact: true }) });
|
||||
await findOnPages(page, card, 'company-loans');
|
||||
await card.locator('summary').click();
|
||||
for (const kind of ['agreement', 'incoming', 'accounting_confirmation']) {
|
||||
const received = page.waitForResponse(r => r.url().endsWith(`/company-loans/${loan.id}/evidences/${kind}`) && r.request().method() === 'POST');
|
||||
@@ -89,6 +105,7 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
await page.screenshot({ path: path.join(output, `${mode}-lenders-after-submit.png`), fullPage: true });
|
||||
await page.getByRole('tab', { name: '出借人', exact: true }).click();
|
||||
const frozenCard = page.locator('section').filter({ has: page.getByRole('heading', { name: `${lenderName} · ${company.name}`, exact: true }) });
|
||||
await findOnPages(page, frozenCard, 'loan-lenders');
|
||||
await expect(frozenCard).toContainText('有还款正在办理');
|
||||
await expect(frozenCard.getByRole('button', { name: '核实身份关联' })).toBeDisabled();
|
||||
await expect(frozenCard.getByRole('button', { name: '更新收款资料' })).toBeDisabled();
|
||||
@@ -155,6 +172,131 @@ module.exports = async function runBrowser({ app, finance, manager, ordinary, pa
|
||||
assert.equal(settled.statusCode, 200, 'settles after UI re-registration and review');
|
||||
checks.push('desktop_stale_attribution_reregistered_reviewed_and_settled');
|
||||
}
|
||||
if (reconciliationUi) {
|
||||
async function confirmDialog(dialog, step) {
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
try { await expect(dialog).not.toBeVisible(); }
|
||||
catch { throw new Error(`synthetic Q6 UI ${step}: ${await dialog.getByRole('alert').allTextContents()} (dialog remains open)`); }
|
||||
}
|
||||
for (const [index, batchId] of reconciliationUi.batches.entries()) {
|
||||
const mobile = index === 1;
|
||||
async function session(who) {
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) })));
|
||||
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batchId}`)}`);
|
||||
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click();
|
||||
await page.waitForURL(/\/payment-batches\?id=/);
|
||||
const panel = page.getByRole('region', { name: '银行结果对账', exact: true }); await expect(panel).toBeVisible({ timeout: 20000 });
|
||||
return { context, page, panel };
|
||||
}
|
||||
let view = await session(finance);
|
||||
for (const kind of ['query_no_result', 'account_statement', 'exporter_statement']) {
|
||||
await view.panel.getByRole('button', { name: '上传银行证据', exact: true }).click();
|
||||
const dialog = view.page.getByRole('dialog'); await dialog.getByLabel('证据类别').selectOption(kind);
|
||||
await dialog.getByLabel('证据文件').setInputFiles({ name: `${kind}.pdf`, mimeType: 'application/pdf', buffer: await reconciliationUi.bankLetterPdf() });
|
||||
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
|
||||
}
|
||||
await view.panel.getByRole('button', { name: '登记人工未提交', exact: true }).click();
|
||||
let dialog = view.page.getByRole('dialog');
|
||||
await dialog.getByLabel('查询无结果官方 PDF', { exact: true }).selectOption({ index: 1 });
|
||||
await dialog.getByLabel('同期对账单', { exact: true }).selectOption({ index: 1 });
|
||||
await dialog.getByLabel('导出人 1 的文件去向说明', { exact: true }).selectOption({ index: 1 });
|
||||
const now = new Date(); const earlier = new Date(now.getTime() - 86400000);
|
||||
// Browser local timezone is UTC in this synthetic context.
|
||||
await dialog.getByLabel('查询与对账单覆盖开始').fill(earlier.toISOString().slice(0, 16));
|
||||
await dialog.getByLabel('查询与对账单覆盖截至(须含登记日)').fill(now.toISOString().slice(0, 16));
|
||||
await dialog.getByLabel('依据与说明').fill('合成浏览器逐份核对官方查询、对账单和文件去向');
|
||||
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
|
||||
await expect(view.panel).toContainText('已有 0 人同意');
|
||||
await expect(view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true })).toHaveCount(0);
|
||||
await view.context.close();
|
||||
for (const [n, who] of [manager, reconciliationUi.independent].entries()) {
|
||||
view = await session(who); await view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true }).click();
|
||||
dialog = view.page.getByRole('dialog'); await dialog.getByLabel('复核结论').selectOption('approve');
|
||||
await dialog.getByLabel('依据与说明').fill('合成浏览器独立复核全部证据与未提交结论');
|
||||
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
|
||||
await expect(view.panel).toContainText(`已有 ${n + 1} 人同意`);
|
||||
await expect(view.panel.getByRole('button', { name: '特别复核人工未提交', exact: true })).toHaveCount(0);
|
||||
await view.context.close();
|
||||
}
|
||||
view = await session(finance); await view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true }).click();
|
||||
dialog = view.page.getByRole('dialog'); await dialog.getByLabel('依据与说明').fill('合成浏览器特别复核完成后重验并取消');
|
||||
await confirmDialog(dialog, await dialog.getByRole('heading').innerText());
|
||||
await expect(view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true })).toHaveCount(0);
|
||||
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
await view.page.screenshot({ path: path.join(output, `${mobile ? 'mobile' : 'desktop'}-manual-not-submitted.png`), fullPage: true });
|
||||
await view.context.close();
|
||||
checks.push(`${mobile ? 'mobile' : 'desktop'}_manual_not_submitted_evidence_upload_two_independent_reviews_and_release`);
|
||||
}
|
||||
}
|
||||
if (reconciliationUi) {
|
||||
for (const recovery of [false, true]) for (const [index, batch] of (recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).entries()) {
|
||||
const mobile = index === 1;
|
||||
async function session(who) {
|
||||
const context = await browser.newContext({ viewport: mobile ? { width: 390, height: 844 } : { width: 1440, height: 1000 }, serviceWorkers: 'block' });
|
||||
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', (error) => console.log(JSON.stringify({ stage: 'synthetic_browser_error', name: error.name, message: error.message.replace(/\d{8,}/g, '[redacted]').slice(0, 240) })));
|
||||
await page.goto(`${origin}/login?redirect=${encodeURIComponent(`${mobile ? '/mobile' : ''}/payment-batches?id=${batch.id}`)}`);
|
||||
await page.getByTestId('login-identifier').fill(who.email); await page.getByTestId('login-password').fill(password); await page.getByTestId('login-submit').click();
|
||||
await page.waitForURL(/\/payment-batches\?id=/);
|
||||
const panel = page.getByRole('region', { name: '银行结果对账', exact: true }); await expect(panel).toBeVisible({ timeout: 20000 });
|
||||
return { context, page, panel };
|
||||
}
|
||||
let view = await session(finance);
|
||||
async function action(name, fill) {
|
||||
await view.panel.getByRole('button', { name, exact: true }).click();
|
||||
const dialog = view.page.getByRole('dialog');
|
||||
if (fill) await fill(dialog);
|
||||
if (await dialog.getByLabel('依据与说明').count()) await dialog.getByLabel('依据与说明').fill('合成浏览器独立核对指定银行指令与全部依据');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click();
|
||||
try { await expect(dialog).not.toBeVisible(); } catch { throw new Error(`synthetic reconciliation ${name}: ${await dialog.getByRole('alert').allTextContents()}`); }
|
||||
}
|
||||
if (recovery) {
|
||||
await action('发起补证恢复');
|
||||
await action('上传银行证据', async (dialog) => {
|
||||
await dialog.getByLabel('证据类别').selectOption('bank_terminal');
|
||||
await dialog.getByLabel('证据文件').setInputFiles({ name: 'replacement-terminal.pdf', mimeType: 'application/pdf', buffer: await reconciliationUi.bankLetterPdf() });
|
||||
});
|
||||
}
|
||||
await action('登记银行终态事实', async (dialog) => {
|
||||
await dialog.getByLabel('对应银行提交').selectOption({ index: 1 });
|
||||
await dialog.getByLabel('银行终态书面证据').selectOption({ index: 1 });
|
||||
await dialog.getByLabel('银行终态', { exact: true }).selectOption('failed');
|
||||
if (!recovery) { const boxes = dialog.getByRole('checkbox'); await expect(boxes).toHaveCount(2); for (const box of await boxes.all()) await box.check(); }
|
||||
});
|
||||
await expect(view.panel).toContainText(batch.instruction); await view.context.close();
|
||||
view = await session(manager);
|
||||
await expect(view.panel).toContainText(batch.instruction);
|
||||
if (!recovery) {
|
||||
await expect(view.panel).toContainText('了结处理中回单');
|
||||
const receiptButtons = view.panel.getByRole('button', { name: /^付款回单 [12]/ });
|
||||
await expect(receiptButtons).toHaveCount(2);
|
||||
for (const receiptButton of await receiptButtons.all()) {
|
||||
await receiptButton.click(); const dialog = view.page.getByRole('dialog');
|
||||
await dialog.getByLabel('依据与说明').fill('合成复核指定银行指令的历史回单事实');
|
||||
const downloaded = view.page.waitForEvent('download');
|
||||
await dialog.getByRole('button', { name: '确认', exact: true }).click(); await downloaded;
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
}
|
||||
await action('独立复核终态事实', async (dialog) => dialog.getByLabel('复核结论').selectOption('approve'));
|
||||
if (!recovery) await action('确认银行未付款');
|
||||
await view.context.close(); view = await session(finance);
|
||||
if (recovery) {
|
||||
await action('送审补证恢复'); await view.context.close(); view = await session(manager);
|
||||
await action('复核补证恢复', async (dialog) => dialog.getByLabel('复核结论').selectOption('approve'));
|
||||
await view.context.close(); view = await session(finance);
|
||||
}
|
||||
await action('重验依据并取消释放');
|
||||
await expect(view.panel.getByRole('button', { name: '重验依据并取消释放', exact: true })).toHaveCount(0);
|
||||
assert.equal(await view.page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false);
|
||||
await view.page.screenshot({ path: path.join(output, `${mobile ? 'mobile' : 'desktop'}-${recovery ? 'recovery' : 'terminal-multiple-receipts'}.png`), fullPage: true });
|
||||
await view.context.close(); checks.push(`${mobile ? 'mobile' : 'desktop'}_${recovery ? 'recovery_start_evidence_review_round_release' : 'terminal_multiple_pending_receipts_independent_review_release'}`);
|
||||
}
|
||||
}
|
||||
const context = await browser.newContext({ serviceWorkers: 'block' });
|
||||
await context.route('**/*', route => route.request().url().startsWith(`${origin}/`) ? route.continue() : route.abort());
|
||||
const page = await context.newPage();
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
module.exports = async function freezeFixture(h) {
|
||||
const { prisma, call, req, finance, manager, verifiedLender, confirmedLoan, repay, exportOrder, uploadEvidence } = h;
|
||||
const freezes = req('./src/services/loanFreezes');
|
||||
const lender = await verifiedLender('合成冻结出借人', null, '4');
|
||||
const loan = await confirmedLoan(lender.id, '1000.00', '合成冻结借款');
|
||||
const first = await repay(lender.id, loan.id, '10.00');
|
||||
await call(manager, 'POST', `/payment-batches/${first.id}/review`, {}); await exportOrder(first.id);
|
||||
const draft = await repay(lender.id, loan.id, '20.00');
|
||||
const request = { employee: { id: finance.id }, headers: {}, id: randomUUID(), ip: "127.0.0.1" };
|
||||
const batch = await prisma.paymentBatch.findUniqueOrThrow({ where: { id: first.id }, include: { items: true } });
|
||||
const open = (kind, key) => prisma.$transaction((tx) => freezes.openReason(tx, batch, request, kind, key));
|
||||
const close = (kind, key) => prisma.$transaction((tx) => freezes.closeReason(tx, batch, request, kind, key, { action: 'synthetic_source_resolved', recordId: randomUUID() }));
|
||||
const key = randomUUID(); const other = randomUUID();
|
||||
const reason = await open('late_success', key); await open('receivable', other);
|
||||
assert.equal((await open('late_success', key)).id, reason.id);
|
||||
await call(manager, 'POST', `/payment-batches/${draft.id}/review`, {}, 409, 'loan.lender_frozen');
|
||||
await call(finance, 'POST', `/payment-batches/${first.id}/payment-order`, {}, 409, 'loan.lender_frozen');
|
||||
await call(finance, 'POST', '/loan-repayments', { lenderId: lender.id, reason: '合成冻结后新付款', requestKey: randomUUID(), allocations: [{ loanId: loan.id, amount: '1.00' }] }, 409, 'loan.lender_frozen');
|
||||
await uploadEvidence(first.id); // Facts remain recordable.
|
||||
await close('late_success', key);
|
||||
assert.equal((await freezes.state(prisma, lender.id)).frozen, true);
|
||||
await call(manager, 'POST', `/payment-batches/${draft.id}/review`, {}, 409, 'loan.lender_frozen');
|
||||
await close('receivable', other);
|
||||
assert.equal((await freezes.state(prisma, lender.id)).frozen, false);
|
||||
await call(manager, 'POST', `/payment-batches/${draft.id}/review`, {});
|
||||
await assert.rejects(prisma.loanFreezeReason.delete({ where: { id: reason.id } }));
|
||||
await assert.rejects(prisma.loanFreezeReason.update({ where: { id: reason.id }, data: { status: 'active' } }));
|
||||
return ['s5_all_active_causes_block_new_payment_review_export_but_allow_facts', 's5_individual_closure_never_unfreezes_other_causes_and_history_immutable'];
|
||||
};
|
||||
@@ -934,6 +934,16 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
resolvesAttachmentIds: [pending.attachment.id] }, 409, 'loan.terminal_fact_invalid');
|
||||
const pF1 = await proposeFact(pend.id, { submissionId: pS1.id, kind: 'termination_evidence', terminalStatus: 'rejected', evidenceId: pEv,
|
||||
resolvesAttachmentIds: [pending.attachment.id] });
|
||||
if (voidFirst) {
|
||||
const url = `/api/payment-batches/${pend.id}/receipt-attachments/${pending.attachment.id}/file`;
|
||||
assert.equal((await app.inject({ method: 'GET', url, headers: manager.headers })).statusCode, 409);
|
||||
const query = '?purpose=loan_reconciliation&reason=' + encodeURIComponent('合成复核已作废的处理中银行事实');
|
||||
const historical = await app.inject({ method: 'GET', url: url + query, headers: manager.headers });
|
||||
assert.equal(historical.statusCode, 200); assert.match(historical.headers['cache-control'], /no-store/);
|
||||
assert.equal((await app.inject({ method: 'GET', url: url + query, headers: ordinary.headers })).statusCode, 403);
|
||||
assert.equal((await app.inject({ method: 'GET', url: url + '?purpose=loan_reconciliation', headers: manager.headers })).statusCode, 400);
|
||||
assert.ok(await prisma.auditLog.count({ where: { target_id: pend.id, action: 'PAYMENT_BATCH_RECEIPT_DOWNLOADED' } }));
|
||||
}
|
||||
await reviewFact(pend.id, pF1.id);
|
||||
const pF2 = await proposeFact(pend.id, { submissionId: pS2.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId: pEv });
|
||||
await reviewFact(pend.id, pF2.id);
|
||||
@@ -1157,6 +1167,12 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
checks.push('s3_evidence_and_fact_history_immutable');
|
||||
checks.push(...await require('./company-loan-recovery-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
||||
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
||||
checks.push(...await require('./company-loan-not-submitted-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
||||
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
||||
checks.push(...await require('./company-loan-freeze-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
||||
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
||||
checks.push(...await require('./company-loan-bank-results-fixture.cjs')({ prisma, pool, call, app, finance, manager, independent, dual, actor,
|
||||
config, req, verifiedLender, confirmedLoan, repay, exportOrder, register, attest, uploadEvidence, proposeFact, reviewFact, confirmNotPaid, receipt }));
|
||||
delete config.loanReleaseWaitDays;
|
||||
|
||||
// History guards in the database.
|
||||
@@ -1257,7 +1273,35 @@ module.exports = async function runLoanHttpFixture(pool, scratch) {
|
||||
assert.equal((await receipt(uiTarget.id, '500', 'SYNTHETIC-UI-ATTRIBUTION', { payeeAccount: uiLender.bankAccount })).result.status, 'matched');
|
||||
const attributionUi = { batchId: uiTarget.id, addCompetitor: uiExported,
|
||||
execute: () => app.inject({ method: 'POST', url: `/api/payment-batches/${uiTarget.id}/execute`, headers: finance.headers, payload: {} }) };
|
||||
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi });
|
||||
// HTTP cases above verify the real three-day gate. UI fixtures use the
|
||||
// supported synthetic wait override so this run does not wait real days.
|
||||
config.loanReleaseWaitDays = 0;
|
||||
const reconciliationUi = { batches: [], independent, bankLetterPdf };
|
||||
const uiManualLender = await verifiedLender('合成特别复核界面出借人', null, '6');
|
||||
const uiManualLoan = await confirmedLoan(uiManualLender.id, '100.00', '合成特别复核界面借款');
|
||||
for (const amount of ['1.00', '2.00']) {
|
||||
const batch = await repay(uiManualLender.id, uiManualLoan.id, amount);
|
||||
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id); await attest(batch.id);
|
||||
reconciliationUi.batches.push(batch.id);
|
||||
}
|
||||
reconciliationUi.terminalBatches = []; reconciliationUi.recoveryBatches = [];
|
||||
for (const recovery of [false, true]) for (const amount of (recovery ? ['5.00', '6.00'] : ['3.00', '4.00'])) {
|
||||
const batch = await repay(uiManualLender.id, uiManualLoan.id, amount);
|
||||
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id);
|
||||
const exported = await prisma.paymentOrderExport.findFirstOrThrow({ where: { payment_batch_id: batch.id } });
|
||||
const ref = `UI-TERMINAL-${amount}`; const sub = await register(batch.id, ref, exported.id); await attest(batch.id);
|
||||
if (recovery) {
|
||||
const evidenceId = await uploadEvidence(batch.id);
|
||||
const fact = await proposeFact(batch.id, { submissionId: sub.id, kind: 'termination_evidence', terminalStatus: 'failed', evidenceId });
|
||||
await reviewFact(batch.id, fact.id); await confirmNotPaid(batch.id);
|
||||
await call(finance, 'POST', `/payment-batches/${batch.id}/loan-terminal-evidences/${evidenceId}/void`, { reason: '合成旧证据撤回,浏览器重新补证' });
|
||||
} else {
|
||||
for (let i = 0; i < 2; i++) { const uploaded = await receipt(batch.id, String(Number(amount) * 100), ref, { payeeAccount: uiManualLender.bankAccount, transactionStatus: 'pending' }); if (i === 0) await call(finance, 'POST', `/payment-batches/${batch.id}/receipt-attachments/${uploaded.attachment.id}/void`, { reason: '合成作废的处理中事实仍供复核' }); }
|
||||
await uploadEvidence(batch.id);
|
||||
}
|
||||
(recovery ? reconciliationUi.recoveryBatches : reconciliationUi.terminalBatches).push({ id: batch.id, instruction: ref });
|
||||
}
|
||||
await require('./company-loan-browser.cjs')({ app, finance, manager, ordinary, password, company, bank, lender, loan, config, prisma, attributionUi, reconciliationUi });
|
||||
}
|
||||
console.log(JSON.stringify({ stage: 'loan_ledger_http', status: 'PASS', checks }));
|
||||
} finally { await app.close(); await prisma.$disconnect(); }
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { randomUUID } = require('node:crypto');
|
||||
module.exports = async function notSubmittedFixture(h) {
|
||||
const { prisma, pool, req, call, app, finance, manager, independent, config,
|
||||
verifiedLender, confirmedLoan, repay, exportOrder, register, attest, receipt } = h;
|
||||
const { PDFDocument } = req("pdf-lib");
|
||||
const checks = [];
|
||||
const lender = await verifiedLender('合成人工未提交出借人', null, '3');
|
||||
const loan = await confirmedLoan(lender.id, '5000.00', '合成人工未提交借入');
|
||||
let amount = 400;
|
||||
const url = (s) => `/payment-batches/${s.batch.id}/loan-not-submitted`;
|
||||
async function upload(s, kind) {
|
||||
const doc = await PDFDocument.create(); doc.setTitle(`synthetic-${randomUUID()}`); doc.addPage([200, 200]);
|
||||
const bytes = Buffer.from(await doc.save()); const boundary = `synthetic-${randomUUID()}`;
|
||||
const res = await app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-terminal-evidences?kind=${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`), bytes, Buffer.from(`\r\n--${boundary}--\r\n`)]) });
|
||||
assert.equal(res.statusCode, 200, `synthetic upload ${res.body.slice(0, 100)}`);
|
||||
return res.json().data.id;
|
||||
}
|
||||
async function setup() {
|
||||
const cents = ++amount;
|
||||
const batch = await repay(lender.id, loan.id, (cents / 100).toFixed(2));
|
||||
await call(manager, 'POST', `/payment-batches/${batch.id}/review`, {}); await exportOrder(batch.id); await attest(batch.id);
|
||||
const exports = await prisma.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id } });
|
||||
const s = { batch, cents, exports };
|
||||
s.input = { exportIds: exports.map((e) => e.id), queryEvidenceId: await upload(s, 'query_no_result'),
|
||||
statementEvidenceId: await upload(s, 'account_statement'),
|
||||
exporterStatements: [{ exporterId: exports[0].exported_by, evidenceId: await upload(s, 'exporter_statement') }],
|
||||
coverageFrom: new Date(Date.now() - 86400000).toISOString(), coverageThrough: new Date().toISOString(), note: '合成逐份核对文件去向及无提交无支出' };
|
||||
return s;
|
||||
}
|
||||
const propose = (s, input = s.input, status = 200, reason) => call(finance, 'POST', url(s), input, status, reason);
|
||||
const review = (s, p, who = manager, decision = 'approve', status = 200, reason) =>
|
||||
call(who, 'POST', `${url(s)}/${p.id}/review`, { decision, reason: '合成独立核对全部证据与提交清单' }, status, reason);
|
||||
const cancel = (s, status = 200, reason) => call(finance, 'POST', `/payment-batches/${s.batch.id}/cancel`, { reason: '合成人工未提交复核后取消' }, status, reason);
|
||||
async function ready() { const s = await setup(); const p = await propose(s); await review(s, p); await review(s, p, independent); return { s, p }; }
|
||||
const s = await setup(); const before = await call(finance, 'GET', `/company-loans/${loan.id}`);
|
||||
const p = await propose(s);
|
||||
await review(s, p, finance, 'approve', 403);
|
||||
await review(s, p);
|
||||
await review(s, p, manager, 'approve', 409, 'loan.not_submitted_invalid');
|
||||
await cancel(s, 409, 'loan.bank_state_unsafe');
|
||||
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.batch.loanRepaymentId } })).bank_result_state, 'unknown');
|
||||
await review(s, p, independent);
|
||||
assert.equal((await prisma.loanRepayment.findUniqueOrThrow({ where: { id: s.batch.loanRepaymentId } })).bank_result_state, 'confirmed_not_submitted');
|
||||
assert.equal((await call(finance, 'GET', `/company-loans/${loan.id}`)).reserved, before.reserved);
|
||||
await cancel(s);
|
||||
const after = await call(finance, 'GET', `/company-loans/${loan.id}`);
|
||||
assert.equal(after.outstanding, before.outstanding); assert.equal(Number(after.reserved), Number((Number(before.reserved) - s.cents / 100).toFixed(2)));
|
||||
await call(finance, 'POST', `/payment-batches/${s.batch.id}/payment-order`, {}, 409);
|
||||
await call(finance, 'POST', `/payment-batches/${s.batch.id}/execute`, {}, 409);
|
||||
checks.push('s4_three_evidence_categories_two_distinct_reviews_then_release_no_principal_change');
|
||||
|
||||
const invalid = await setup();
|
||||
await propose(invalid, { ...invalid.input, queryEvidenceId: invalid.input.statementEvidenceId }, 409, 'loan.not_submitted_invalid');
|
||||
const wrongCategory = await upload(invalid, 'bank_terminal');
|
||||
await propose(invalid, { ...invalid.input, queryEvidenceId: wrongCategory }, 409, 'loan.not_submitted_invalid');
|
||||
await propose(invalid, { ...invalid.input, exporterStatements: [] }, 400);
|
||||
await propose(invalid, { ...invalid.input, exportIds: [randomUUID()] }, 409, 'loan.not_submitted_invalid');
|
||||
await propose(invalid, { ...invalid.input, coverageThrough: new Date(Date.now() - 86400000 * 2).toISOString() }, 409, 'loan.not_submitted_invalid');
|
||||
const oldWait = config.loanReleaseWaitDays; config.loanReleaseWaitDays = 3;
|
||||
try { await propose(invalid, undefined, 409, 'loan.release_wait_period'); } finally { config.loanReleaseWaitDays = oldWait; }
|
||||
const ip = await propose(invalid); await review(invalid, ip, manager, 'reject'); await cancel(invalid, 409);
|
||||
const again = await propose(invalid); await review(invalid, again);
|
||||
await exportOrder(invalid.batch.id);
|
||||
await review(invalid, again, independent, 'approve', 409, 'loan.not_submitted_invalid');
|
||||
checks.push('s4_missing_wrong_category_coverage_wait_and_changed_export_fail_closed');
|
||||
|
||||
const stale = await ready();
|
||||
await call(finance, 'POST', `/payment-batches/${stale.s.batch.id}/loan-terminal-evidences/${stale.s.input.queryEvidenceId}/void`, { reason: '合成旧材料作废' });
|
||||
await cancel(stale.s, 409, 'loan.not_submitted_invalid');
|
||||
stale.s.input.queryEvidenceId = await upload(stale.s, 'query_no_result');
|
||||
const replacement = await propose(stale.s); await review(stale.s, replacement); await review(stale.s, replacement, independent); await cancel(stale.s);
|
||||
checks.push('s4_voided_evidence_invalidates_both_reviews_fresh_round_restores_release');
|
||||
|
||||
const late = await ready();
|
||||
await receipt(late.s.batch.id, String(late.s.cents), `S4-LATE-${randomUUID()}`, { payeeAccount: lender.bankAccount });
|
||||
await cancel(late.s, 409, 'loan.not_submitted_invalid');
|
||||
const sub = await ready(); await register(sub.s.batch.id, `S4-SUB-${randomUUID()}`, sub.s.exports[0].id);
|
||||
await cancel(sub.s, 409, 'loan.not_submitted_invalid');
|
||||
checks.push('s4_late_receipt_and_new_submission_block_final_release');
|
||||
|
||||
// Existing bank-side facts cannot become 'never submitted' by withdrawal.
|
||||
const wd = await setup(); const submission = await register(wd.batch.id, `S4-WITHDRAW-${randomUUID()}`, wd.exports[0].id);
|
||||
await call(finance, 'POST', `/payment-batches/${wd.batch.id}/loan-bank-submissions/${submission.id}/withdraw`, { reason: '合成撤回登记' });
|
||||
await propose(wd, undefined, 409, 'loan.not_submitted_invalid');
|
||||
checks.push('s4_withdrawn_submission_not_erased_by_manual_exception');
|
||||
|
||||
const { Pool } = req('pg'); const lockPool = new Pool({ ...pool.options, max: 3 });
|
||||
async function waiters(n) {
|
||||
for (let i = 0; i < 300; i++) {
|
||||
const result = await lockPool.query("SELECT count(*)::int AS n FROM pg_stat_activity WHERE wait_event_type='Lock' AND datname=current_database()");
|
||||
if (result.rows[0].n >= n) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error('manual exception requests did not queue');
|
||||
}
|
||||
try {
|
||||
for (const releaseFirst of [false, true]) {
|
||||
const { s } = await ready(); const holder = await lockPool.connect();
|
||||
const release = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/cancel`, headers: finance.headers, payload: { reason: '合成并发人工释放' } });
|
||||
const mutate = () => app.inject({ method: 'POST', url: `/api/payment-batches/${s.batch.id}/loan-terminal-evidences/${s.input.queryEvidenceId}/void`, headers: finance.headers, payload: { reason: '合成并发作废查询证据' } });
|
||||
let first, second;
|
||||
try {
|
||||
await holder.query('BEGIN'); await holder.query('SELECT id FROM payment_batches WHERE id=$1 FOR UPDATE', [s.batch.id]);
|
||||
first = releaseFirst ? release() : mutate(); await waiters(1);
|
||||
second = releaseFirst ? mutate() : release(); await waiters(2);
|
||||
await holder.query('COMMIT'); [first, second] = await Promise.all([first, second]);
|
||||
} finally { await holder.query('ROLLBACK'); holder.release(); }
|
||||
assert.equal(first.statusCode, 200); assert.equal(second.statusCode, 409);
|
||||
assert.equal((await prisma.loanRepaymentAllocation.findFirst({ where: { repayment_id: s.batch.loanRepaymentId } })).reservation_state, releaseFirst ? 'released' : 'reserved');
|
||||
}
|
||||
} finally { await lockPool.end(); }
|
||||
checks.push('s4_final_release_and_evidence_void_deterministic_both_orders');
|
||||
|
||||
{
|
||||
const { s } = await ready();
|
||||
const file = await prisma.loanBankTerminalEvidence.findUniqueOrThrow({ where: { id: s.input.queryEvidenceId } });
|
||||
const { readFileBuffer, writeBuffer } = req('./src/services/storage');
|
||||
const original = await readFileBuffer({ config, storagePath: file.storage_path });
|
||||
await writeBuffer({ config, key: file.storage_path, buffer: Buffer.from('synthetic corrupted Q6 evidence') });
|
||||
try { await cancel(s, 409, 'loan.not_submitted_invalid'); }
|
||||
finally { await writeBuffer({ config, key: file.storage_path, buffer: original }); }
|
||||
await cancel(s);
|
||||
const changed = await ready(); await upload(changed.s, 'bank_terminal');
|
||||
await cancel(changed.s, 409, 'loan.not_submitted_invalid');
|
||||
}
|
||||
checks.push('s4_final_release_requires_physical_digest_and_unchanged_material_revision');
|
||||
// Direct SQL cannot fabricate a second review, rewrite history or reclassify.
|
||||
await assert.rejects(prisma.loanNotSubmittedReview.updateMany({ where: { proposal_id: p.id }, data: { reason: 'tampered' } }));
|
||||
await assert.rejects(prisma.loanNotSubmittedReview.deleteMany({ where: { proposal_id: p.id } }));
|
||||
await assert.rejects(prisma.loanBankTerminalEvidence.update({ where: { id: invalid.input.queryEvidenceId }, data: { evidence_kind: 'bank_terminal' } }));
|
||||
const one = await setup(); const op = await propose(one); await review(one, op);
|
||||
await assert.rejects(prisma.loanNotSubmittedProposal.update({ where: { id: op.id }, data: { status: 'approved' } }));
|
||||
await assert.rejects(prisma.loanRepayment.update({ where: { id: one.batch.loanRepaymentId }, data: { bank_result_state: 'confirmed_not_submitted', bank_result_ref: 'invalid-manual' } }));
|
||||
checks.push('s4_database_two_person_decisions_and_evidence_kind_immutable');
|
||||
console.log(JSON.stringify({ stage: "s4", status: "PASS", checks }));
|
||||
return checks;
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
-- S4: separately reviewed manual no-submission exception, never bank finality.
|
||||
ALTER TABLE loan_bank_terminal_evidences ADD COLUMN evidence_kind VARCHAR(32) NOT NULL DEFAULT 'bank_terminal'
|
||||
CHECK(evidence_kind IN ('bank_terminal','query_no_result','account_statement','exporter_statement'));
|
||||
CREATE FUNCTION guard_loan_evidence_kind() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.evidence_kind IS DISTINCT FROM OLD.evidence_kind THEN RAISE EXCEPTION 'evidence category immutable' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_evidence_kind_guard BEFORE UPDATE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION guard_loan_evidence_kind();
|
||||
CREATE TABLE loan_not_submitted_proposals (
|
||||
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
||||
sequence INTEGER NOT NULL CHECK(sequence>0), status VARCHAR(16) NOT NULL DEFAULT 'proposed' CHECK(status IN ('proposed','approved','rejected','superseded')),
|
||||
proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
payload_json JSONB NOT NULL, snapshot_json JSONB NOT NULL, UNIQUE(payment_batch_id,sequence)
|
||||
);
|
||||
CREATE UNIQUE INDEX loan_not_submitted_open ON loan_not_submitted_proposals(payment_batch_id) WHERE status IN ('proposed','approved');
|
||||
CREATE TABLE loan_not_submitted_reviews (
|
||||
id UUID PRIMARY KEY, proposal_id UUID NOT NULL REFERENCES loan_not_submitted_proposals(id) ON DELETE RESTRICT,
|
||||
reviewed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
decision VARCHAR(16) NOT NULL CHECK(decision IN ('approve','reject')), reason VARCHAR(500) NOT NULL CHECK(length(trim(reason))>=2),
|
||||
reviewed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(proposal_id,reviewed_by)
|
||||
);
|
||||
CREATE FUNCTION guard_loan_not_submitted() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE p loan_not_submitted_proposals; b payment_batches;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'manual fact history immutable' USING ERRCODE='23514'; END IF;
|
||||
IF TG_TABLE_NAME='loan_not_submitted_reviews' THEN
|
||||
IF TG_OP<>'INSERT' THEN RAISE EXCEPTION 'manual review immutable' USING ERRCODE='23514'; END IF;
|
||||
SELECT * INTO p FROM loan_not_submitted_proposals WHERE id=NEW.proposal_id;
|
||||
PERFORM id FROM payment_batches WHERE id=p.payment_batch_id FOR UPDATE;
|
||||
IF p.status<>'proposed' OR NEW.reviewed_by=p.proposed_by OR
|
||||
(SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id)>=2 THEN
|
||||
RAISE EXCEPTION 'invalid manual independent review' USING ERRCODE='23514';
|
||||
END IF;
|
||||
ELSE
|
||||
SELECT * INTO b FROM payment_batches WHERE id=NEW.payment_batch_id FOR UPDATE;
|
||||
IF b.entity_type<>'loan_repayment' OR b.status NOT IN ('reviewed','failed') THEN RAISE EXCEPTION 'manual fact batch unavailable' USING ERRCODE='23514'; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'proposed' OR NEW.sequence<>COALESCE((SELECT max(sequence)+1 FROM loan_not_submitted_proposals WHERE payment_batch_id=b.id),1) THEN
|
||||
RAISE EXCEPTION 'manual fact initial state invalid' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF (to_jsonb(NEW)-'status') IS DISTINCT FROM (to_jsonb(OLD)-'status') OR
|
||||
NOT ((OLD.status='proposed' AND NEW.status IN ('approved','rejected','superseded')) OR (OLD.status='approved' AND NEW.status='superseded')) THEN
|
||||
RAISE EXCEPTION 'manual fact history immutable' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.status='approved' AND (SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=NEW.id AND decision='approve')<>2 THEN
|
||||
RAISE EXCEPTION 'two independent approvals required' USING ERRCODE='23514'; END IF;
|
||||
IF NEW.status='rejected' AND NOT EXISTS(SELECT 1 FROM loan_not_submitted_reviews WHERE proposal_id=NEW.id AND decision='reject') THEN
|
||||
RAISE EXCEPTION 'rejection required' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_not_submitted_history BEFORE INSERT OR UPDATE OR DELETE ON loan_not_submitted_proposals FOR EACH ROW EXECUTE FUNCTION guard_loan_not_submitted();
|
||||
CREATE TRIGGER loan_not_submitted_review_history BEFORE INSERT OR UPDATE OR DELETE ON loan_not_submitted_reviews FOR EACH ROW EXECUTE FUNCTION guard_loan_not_submitted();
|
||||
ALTER TABLE loan_repayments DROP CONSTRAINT loan_repayments_amount_state_check;
|
||||
ALTER TABLE loan_repayments ADD CONSTRAINT loan_repayments_amount_state_check CHECK (
|
||||
amount > 0 AND currency_code = 'CNY' AND version > 0
|
||||
AND lender_identity_version > 0 AND lender_account_version > 0
|
||||
AND bank_result_state IN ('not_sent','unknown','confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success')
|
||||
AND (bank_result_state NOT IN ('confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success') OR NULLIF(trim(bank_result_ref),'') IS NOT NULL)
|
||||
AND ((accounting_handoff_by IS NULL AND accounting_handoff_at IS NULL AND accounting_handoff_ref IS NULL)
|
||||
OR (accounting_handoff_by IS NOT NULL AND accounting_handoff_at IS NOT NULL AND NULLIF(trim(accounting_handoff_ref),'') IS NOT NULL))
|
||||
);
|
||||
|
||||
-- Integrity and history functions re-created with confirmed_not_paid as a
|
||||
-- cancellable terminal state (otherwise identical to 20260917010000).
|
||||
CREATE OR REPLACE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid;
|
||||
BEGIN
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid;
|
||||
IF NOT FOUND THEN RETURN; END IF;
|
||||
SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id
|
||||
OR b.default_payment_channel IS DISTINCT FROM 'bank'
|
||||
OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1
|
||||
OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id
|
||||
AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code)
|
||||
THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount
|
||||
OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id
|
||||
WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id
|
||||
OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version))
|
||||
THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <>
|
||||
CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END)
|
||||
OR (b.status='executed' AND r.bank_result_state<>'confirmed_success')
|
||||
OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed','confirmed_not_paid','confirmed_not_submitted'))
|
||||
THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF;
|
||||
FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP
|
||||
PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations
|
||||
WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) >
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid)
|
||||
THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION guard_loan_repayment_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='loan_repayment_allocations' THEN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan allocation history must be retained' USING ERRCODE='23514'; END IF;
|
||||
IF (NEW.repayment_id,NEW.loan_id,NEW.amount,NEW.loan_version) IS DISTINCT FROM
|
||||
(OLD.repayment_id,OLD.loan_id,OLD.amount,OLD.loan_version)
|
||||
OR (OLD.reservation_state IN ('settled','released') AND NEW IS DISTINCT FROM OLD)
|
||||
THEN RAISE EXCEPTION 'loan allocation history is immutable' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'loan repayment history must be retained' USING ERRCODE='23514'; END IF;
|
||||
IF (NEW.repayment_no,NEW.legal_entity_id,NEW.lender_id,NEW.currency_code,NEW.reason,NEW.amount,
|
||||
NEW.submitted_by,NEW.submitted_at,NEW.lender_identity_version,NEW.lender_account_version,NEW.payee_snapshot_json,NEW.request_key)
|
||||
IS DISTINCT FROM
|
||||
(OLD.repayment_no,OLD.legal_entity_id,OLD.lender_id,OLD.currency_code,OLD.reason,OLD.amount,
|
||||
OLD.submitted_by,OLD.submitted_at,OLD.lender_identity_version,OLD.lender_account_version,OLD.payee_snapshot_json,OLD.request_key)
|
||||
OR (OLD.bank_result_state IN ('confirmed_failed','confirmed_not_paid','confirmed_not_submitted','confirmed_success') AND
|
||||
(NEW.bank_result_state,NEW.bank_result_ref) IS DISTINCT FROM (OLD.bank_result_state,OLD.bank_result_ref))
|
||||
OR (OLD.bank_result_state='unknown' AND NEW.bank_result_state='not_sent')
|
||||
OR (OLD.accounting_handoff_at IS NOT NULL AND
|
||||
(NEW.accounting_handoff_at,NEW.accounting_handoff_by,NEW.accounting_handoff_ref) IS DISTINCT FROM
|
||||
(OLD.accounting_handoff_at,OLD.accounting_handoff_by,OLD.accounting_handoff_ref))
|
||||
THEN RAISE EXCEPTION 'loan repayment history is immutable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
|
||||
-- Even a direct SQL state change requires two distinct, recorded approvals.
|
||||
CREATE FUNCTION check_loan_manual_conclusion() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments; bid UUID; p loan_not_submitted_proposals;
|
||||
BEGIN
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=NEW.id;
|
||||
IF r.bank_result_state<>'confirmed_not_submitted' THEN RETURN NEW; END IF;
|
||||
SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
SELECT * INTO p FROM loan_not_submitted_proposals WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
|
||||
IF p.id IS NULL OR p.status<>'approved' OR (SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id AND decision='approve')<>2 THEN
|
||||
RAISE EXCEPTION 'manual conclusion requires two approvals' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_manual_conclusion AFTER INSERT OR UPDATE ON loan_repayments DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_manual_conclusion();
|
||||
|
||||
-- The Q6 exception attests an empty submission list, with actual exports.
|
||||
ALTER TABLE loan_bank_submission_attestations DROP CONSTRAINT loan_bank_submission_attestations_ids_check;
|
||||
ALTER TABLE loan_bank_submission_attestations ADD CONSTRAINT loan_bank_submission_attestations_ids_check CHECK (
|
||||
jsonb_typeof(submission_ids)='array' AND jsonb_typeof(all_submission_ids)='array' AND jsonb_typeof(export_ids)='array'
|
||||
AND (jsonb_array_length(submission_ids)>0 OR (jsonb_array_length(all_submission_ids)=0 AND jsonb_array_length(export_ids)>0))
|
||||
);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- S5: each cause closes separately. No administrator unfreeze switch.
|
||||
CREATE TABLE loan_freeze_reasons (
|
||||
id UUID PRIMARY KEY, lender_id UUID NOT NULL REFERENCES loan_lenders(id) ON DELETE RESTRICT,
|
||||
payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
||||
kind VARCHAR(32) NOT NULL CHECK(kind IN ('late_success','reservation_conflict','receivable')),
|
||||
source_key VARCHAR(160) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'active' CHECK(status IN ('active','closed')),
|
||||
created_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
closed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, closed_at TIMESTAMP(3), closure_basis JSONB,
|
||||
UNIQUE(kind,source_key), CHECK((status='closed')=(closed_by IS NOT NULL AND closed_at IS NOT NULL AND closure_basis IS NOT NULL))
|
||||
);
|
||||
CREATE INDEX loan_freeze_reasons_lender_id_status_idx ON loan_freeze_reasons(lender_id,status);
|
||||
CREATE FUNCTION guard_loan_freeze_reason() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE lender UUID;
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'freeze history retained' USING ERRCODE='23514'; END IF;
|
||||
SELECT r.lender_id INTO lender FROM payment_batches b JOIN loan_repayments r ON r.id=b.loan_repayment_id WHERE b.id=NEW.payment_batch_id;
|
||||
IF lender IS DISTINCT FROM NEW.lender_id THEN RAISE EXCEPTION 'freeze source mismatch' USING ERRCODE='23514'; END IF;
|
||||
PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'active' OR NEW.closed_by IS NOT NULL OR NEW.closed_at IS NOT NULL OR NEW.closure_basis IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'new freeze must be active' USING ERRCODE='23514'; END IF;
|
||||
ELSIF OLD.status<>'active' OR NEW.status<>'closed' OR
|
||||
(to_jsonb(NEW)-ARRAY['status','closed_by','closed_at','closure_basis']) IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','closed_by','closed_at','closure_basis']) THEN
|
||||
RAISE EXCEPTION 'freeze history immutable' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_freeze_history BEFORE INSERT OR UPDATE OR DELETE ON loan_freeze_reasons FOR EACH ROW EXECUTE FUNCTION guard_loan_freeze_reason();
|
||||
@@ -0,0 +1,224 @@
|
||||
-- S6/S7: append actual bank payments and returns, never issue a payment batch.
|
||||
CREATE TABLE loan_bank_result_events (
|
||||
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
|
||||
kind VARCHAR(16) NOT NULL CHECK(kind IN ('late_success','return')), parent_id UUID REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
evidence_id UUID NOT NULL REFERENCES loan_bank_terminal_evidences(id) ON DELETE RESTRICT,
|
||||
request_key UUID NOT NULL, proposed_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT,
|
||||
proposed_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, payload_encrypted TEXT NOT NULL,
|
||||
fingerprint VARCHAR(64) NOT NULL, key_id VARCHAR(64) NOT NULL, amount DECIMAL(14,2) NOT NULL CHECK(amount>0), snapshot_json JSONB NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'proposed' CHECK(status IN ('proposed','approved','booked','dismissed','rejected','superseded')),
|
||||
reviewed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, reviewed_at TIMESTAMP(3), review_note VARCHAR(1000), correction_evidence_id UUID REFERENCES loan_bank_terminal_evidences(id) ON DELETE RESTRICT,
|
||||
UNIQUE(payment_batch_id,request_key), CHECK((kind='return')=(parent_id IS NOT NULL)),
|
||||
CHECK(reviewed_by IS NULL OR reviewed_by<>proposed_by),
|
||||
CHECK(status IN ('proposed','superseded') OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_note))>=5))
|
||||
);
|
||||
CREATE INDEX loan_bank_result_events_payment_batch_id_kind_idx ON loan_bank_result_events(payment_batch_id,kind);
|
||||
CREATE TABLE loan_supplemental_settlements (
|
||||
id UUID PRIMARY KEY, event_id UUID NOT NULL REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
loan_id UUID NOT NULL REFERENCES company_loans(id) ON DELETE RESTRICT,
|
||||
bank_amount DECIMAL(14,2) NOT NULL CHECK(bank_amount>0), settled_amount DECIMAL(14,2) NOT NULL CHECK(settled_amount>=0),
|
||||
receivable_amount DECIMAL(14,2) NOT NULL CHECK(receivable_amount>=0), created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(event_id,loan_id), CHECK(bank_amount=settled_amount+receivable_amount)
|
||||
);
|
||||
CREATE TABLE loan_bank_event_claims (
|
||||
fingerprint VARCHAR(64) PRIMARY KEY, key_id VARCHAR(64) NOT NULL,
|
||||
event_id UUID UNIQUE NOT NULL REFERENCES loan_bank_result_events(id) ON DELETE RESTRICT,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TRIGGER loan_supplemental_history BEFORE UPDATE OR DELETE ON loan_supplemental_settlements FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
CREATE TRIGGER loan_bank_event_claim_history BEFORE UPDATE OR DELETE ON loan_bank_event_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_reconciliation_append_only();
|
||||
CREATE FUNCTION guard_loan_bank_event_history() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF TG_OP='DELETE' THEN RAISE EXCEPTION 'bank event history retained' USING ERRCODE='23514'; END IF;
|
||||
IF TG_OP='INSERT' THEN
|
||||
IF NEW.status<>'proposed' OR NEW.reviewed_by IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR NEW.correction_evidence_id IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'bank event initial state invalid' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF (to_jsonb(NEW)-ARRAY['status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) IS DISTINCT FROM
|
||||
(to_jsonb(OLD)-ARRAY['status','reviewed_by','reviewed_at','review_note','correction_evidence_id']) OR
|
||||
NOT ((OLD.status='proposed' AND NEW.status IN ('approved','dismissed','rejected','superseded')) OR (OLD.status='approved' AND NEW.status IN ('booked','superseded'))) THEN
|
||||
RAISE EXCEPTION 'bank event immutable' USING ERRCODE='23514'; END IF;
|
||||
IF OLD.status='approved' AND (NEW.reviewed_by,NEW.reviewed_at,NEW.review_note,NEW.correction_evidence_id) IS DISTINCT FROM
|
||||
(OLD.reviewed_by,OLD.reviewed_at,OLD.review_note,OLD.correction_evidence_id) THEN RAISE EXCEPTION 'bank event review immutable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_bank_event_history BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_result_events FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_event_history();
|
||||
CREATE OR REPLACE FUNCTION assert_loan_repayment_integrity(repayment_uuid uuid) RETURNS void LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments%ROWTYPE; b payment_batches%ROWTYPE; n integer; loan_uuid uuid;
|
||||
BEGIN
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=repayment_uuid;
|
||||
IF NOT FOUND THEN RETURN; END IF;
|
||||
SELECT count(*) INTO n FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF n <> 1 THEN RAISE EXCEPTION 'repayment requires one retained batch' USING ERRCODE='23514'; END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF b.entity_type::text <> 'loan_repayment' OR b.legal_entity_id IS DISTINCT FROM r.legal_entity_id
|
||||
OR b.default_payment_channel IS DISTINCT FROM 'bank'
|
||||
OR (SELECT count(*) FROM payment_batch_items WHERE payment_batch_id=b.id) <> 1
|
||||
OR NOT EXISTS(SELECT 1 FROM payment_batch_items WHERE payment_batch_id=b.id
|
||||
AND entity_id=r.id AND amount=r.amount AND currency_code=r.currency_code)
|
||||
THEN RAISE EXCEPTION 'repayment payment identity mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations WHERE repayment_id=r.id) <> r.amount
|
||||
OR EXISTS(SELECT 1 FROM loan_repayment_allocations a JOIN company_loans l ON l.id=a.loan_id
|
||||
WHERE a.repayment_id=r.id AND (l.lender_id<>r.lender_id OR l.legal_entity_id<>r.legal_entity_id
|
||||
OR l.currency_code<>r.currency_code OR l.confirmation_status<>'confirmed' OR l.version<>a.loan_version))
|
||||
THEN RAISE EXCEPTION 'repayment allocation mismatch' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state <>
|
||||
CASE WHEN b.status='executed' THEN 'settled' WHEN b.status='cancelled' THEN 'released' ELSE 'reserved' END)
|
||||
OR (b.status='executed' AND r.bank_result_state<>'confirmed_success')
|
||||
OR (b.status='cancelled' AND r.bank_result_state NOT IN ('not_sent','confirmed_failed','confirmed_not_paid','confirmed_not_submitted'))
|
||||
THEN RAISE EXCEPTION 'repayment release or settlement state mismatch' USING ERRCODE='23514'; END IF;
|
||||
FOR loan_uuid IN SELECT loan_id FROM loan_repayment_allocations WHERE repayment_id=r.id ORDER BY loan_id LOOP
|
||||
PERFORM 1 FROM company_loans WHERE id=loan_uuid FOR UPDATE;
|
||||
IF (SELECT coalesce(sum(amount),0) FROM loan_repayment_allocations
|
||||
WHERE loan_id=loan_uuid AND reservation_state IN ('reserved','settled')) +
|
||||
(SELECT coalesce(sum(settled_amount),0) FROM loan_supplemental_settlements WHERE loan_id=loan_uuid) >
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=loan_uuid)
|
||||
THEN RAISE EXCEPTION 'loan principal capacity exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
|
||||
CREATE FUNCTION check_loan_supplemental_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE e loan_bank_result_events; lid UUID; total NUMERIC;
|
||||
BEGIN
|
||||
SELECT * INTO e FROM loan_bank_result_events WHERE id=NEW.event_id;
|
||||
IF e.kind<>'late_success' OR e.status<>'booked' OR
|
||||
(SELECT coalesce(sum(bank_amount),0) FROM loan_supplemental_settlements WHERE event_id=e.id)<>e.amount THEN
|
||||
RAISE EXCEPTION 'supplemental event totals invalid' USING ERRCODE='23514'; END IF;
|
||||
IF EXISTS(SELECT 1 FROM loan_supplemental_settlements s WHERE s.event_id=e.id AND NOT EXISTS (
|
||||
SELECT 1 FROM loan_repayment_allocations a JOIN payment_batches b ON b.loan_repayment_id=a.repayment_id
|
||||
WHERE b.id=e.payment_batch_id AND a.loan_id=s.loan_id AND a.amount=s.bank_amount AND a.reservation_state='released')) THEN
|
||||
RAISE EXCEPTION 'supplemental source allocation invalid' USING ERRCODE='23514'; END IF;
|
||||
FOR lid IN SELECT loan_id FROM loan_supplemental_settlements WHERE event_id=e.id ORDER BY loan_id LOOP
|
||||
PERFORM id FROM company_loans WHERE id=lid FOR UPDATE;
|
||||
SELECT coalesce(sum(amount),0) INTO total FROM loan_repayment_allocations WHERE loan_id=lid AND reservation_state IN ('reserved','settled');
|
||||
IF total+(SELECT coalesce(sum(settled_amount),0) FROM loan_supplemental_settlements WHERE loan_id=lid)>
|
||||
(SELECT opening_outstanding_principal FROM company_loans WHERE id=lid) THEN RAISE EXCEPTION 'supplemental principal exceeded' USING ERRCODE='23514'; END IF;
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_supplemental_integrity AFTER INSERT ON loan_supplemental_settlements DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_supplemental_integrity();
|
||||
CREATE OR REPLACE FUNCTION check_loan_recovery_release() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE bid UUID; latest loan_release_recoveries; b payment_batches; rid UUID;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME='loan_release_recoveries' THEN bid:=NEW.payment_batch_id;
|
||||
ELSE SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id; END IF;
|
||||
SELECT * INTO latest FROM loan_release_recoveries WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
|
||||
IF latest.id IS NULL OR EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN RETURN NEW; END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE id=bid;
|
||||
rid:=b.loan_repayment_id;
|
||||
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state='released') AND
|
||||
(latest.status<>'consumed' OR b.status<>'cancelled') THEN
|
||||
RAISE EXCEPTION 'release requires latest recovery consumption' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF latest.status='consumed' AND (b.status<>'cancelled' OR EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state<>'released')) THEN
|
||||
RAISE EXCEPTION 'recovery consumption requires atomic cancellation' USING ERRCODE='23514';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
CREATE FUNCTION guard_loan_bank_transaction_identity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
PERFORM pg_advisory_xact_lock(1002,1);
|
||||
IF TG_TABLE_NAME='loan_bank_event_claims' THEN
|
||||
IF EXISTS(SELECT 1 FROM payment_receipt_claims WHERE fingerprint=NEW.fingerprint) OR
|
||||
EXISTS(SELECT 1 FROM payment_receipt_transaction_aliases WHERE fingerprint=NEW.fingerprint) THEN
|
||||
RAISE EXCEPTION 'bank transaction already used' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_event_claims WHERE fingerprint=NEW.fingerprint) THEN
|
||||
RAISE EXCEPTION 'bank transaction already supplemented' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON loan_bank_event_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON payment_receipt_claims FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
CREATE TRIGGER loan_bank_identity BEFORE INSERT ON payment_receipt_transaction_aliases FOR EACH ROW EXECUTE FUNCTION guard_loan_bank_transaction_identity();
|
||||
|
||||
ALTER TABLE loan_bank_terminal_evidences DROP CONSTRAINT loan_bank_terminal_evidences_evidence_kind_check;
|
||||
ALTER TABLE loan_bank_terminal_evidences ADD CONSTRAINT loan_bank_terminal_evidences_evidence_kind_check CHECK(
|
||||
evidence_kind IN ('bank_terminal','query_no_result','account_statement','exporter_statement','late_success','incoming_return'));
|
||||
CREATE OR REPLACE FUNCTION touch_loan_release_material() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE bid UUID; b payment_batches; lender UUID; data JSONB;
|
||||
BEGIN
|
||||
data := CASE WHEN TG_OP='DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END;
|
||||
bid := (data->>'payment_batch_id')::uuid;
|
||||
IF bid IS NULL THEN
|
||||
SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(data->>'attachment_id')::uuid;
|
||||
END IF;
|
||||
SELECT * INTO b FROM payment_batches WHERE id=bid FOR UPDATE;
|
||||
IF b.entity_type='loan_repayment' THEN
|
||||
IF EXISTS(SELECT 1 FROM loan_release_recoveries WHERE payment_batch_id=bid) AND b.status IN ('cancelled','executed') AND NOT (TG_OP='INSERT' AND TG_TABLE_NAME='loan_bank_terminal_evidences') THEN
|
||||
RAISE EXCEPTION 'closed recovery evidence is immutable' USING ERRCODE='23514';
|
||||
END IF;
|
||||
IF TG_OP='INSERT' AND TG_TABLE_NAME IN ('payment_order_exports','loan_bank_submissions','payment_batch_receipt_attachments') THEN
|
||||
SELECT lender_id INTO lender FROM loan_repayments WHERE id=b.loan_repayment_id;
|
||||
PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE;
|
||||
END IF;
|
||||
INSERT INTO loan_release_material_revisions(payment_batch_id,revision) VALUES(bid,1)
|
||||
ON CONFLICT(payment_batch_id) DO UPDATE SET revision=loan_release_material_revisions.revision+1;
|
||||
END IF;
|
||||
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
|
||||
-- Business application cannot bypass atomic fact/claim/ledger consistency.
|
||||
CREATE FUNCTION check_loan_bank_event_integrity() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE e loan_bank_result_events; parent loan_bank_result_events; total NUMERIC; expected NUMERIC;
|
||||
BEGIN
|
||||
SELECT * INTO e FROM loan_bank_result_events WHERE id=NEW.id;
|
||||
IF e.status='booked' AND NOT EXISTS(SELECT 1 FROM loan_bank_event_claims WHERE event_id=e.id AND fingerprint=e.fingerprint AND key_id=e.key_id) THEN
|
||||
RAISE EXCEPTION 'bank event requires unique claim' USING ERRCODE='23514'; END IF;
|
||||
IF e.kind='late_success' THEN
|
||||
IF NOT EXISTS(SELECT 1 FROM loan_freeze_reasons WHERE payment_batch_id=e.payment_batch_id AND kind='late_success' AND source_key=e.id::text) THEN
|
||||
RAISE EXCEPTION 'late event requires recorded freeze' USING ERRCODE='23514'; END IF;
|
||||
IF e.status='booked' AND (SELECT coalesce(sum(bank_amount),0) FROM loan_supplemental_settlements WHERE event_id=e.id)<>e.amount THEN
|
||||
RAISE EXCEPTION 'late event requires complete supplemental allocation' USING ERRCODE='23514'; END IF;
|
||||
ELSE
|
||||
SELECT * INTO parent FROM loan_bank_result_events WHERE id=e.parent_id;
|
||||
IF parent.kind<>'late_success' OR parent.payment_batch_id<>e.payment_batch_id OR parent.status<>'booked' THEN
|
||||
RAISE EXCEPTION 'return parent invalid' USING ERRCODE='23514'; END IF;
|
||||
SELECT coalesce(sum(receivable_amount),0) INTO expected FROM loan_supplemental_settlements WHERE event_id=e.parent_id;
|
||||
SELECT coalesce(sum(amount),0) INTO total FROM loan_bank_result_events WHERE parent_id=e.parent_id AND kind='return' AND status='booked';
|
||||
IF total>expected THEN RAISE EXCEPTION 'return exceeds receivable' USING ERRCODE='23514'; END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_bank_event_integrity AFTER INSERT OR UPDATE ON loan_bank_result_events DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_bank_event_integrity();
|
||||
|
||||
CREATE FUNCTION check_loan_manual_release() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE r loan_repayments; bid UUID; p loan_not_submitted_proposals; revision BIGINT;
|
||||
BEGIN
|
||||
IF NEW.reservation_state<>'released' THEN RETURN NEW; END IF;
|
||||
SELECT * INTO r FROM loan_repayments WHERE id=NEW.repayment_id;
|
||||
IF r.bank_result_state<>'confirmed_not_submitted' THEN RETURN NEW; END IF;
|
||||
SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=r.id;
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN RETURN NEW; END IF;
|
||||
SELECT * INTO p FROM loan_not_submitted_proposals WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
|
||||
SELECT m.revision INTO revision FROM loan_release_material_revisions m WHERE payment_batch_id=bid;
|
||||
IF p.id IS NULL OR p.status<>'approved' OR (p.snapshot_json->>'revision')::bigint IS DISTINCT FROM COALESCE(revision,0) OR
|
||||
(SELECT count(*) FROM loan_not_submitted_reviews WHERE proposal_id=p.id AND decision='approve')<>2 THEN
|
||||
RAISE EXCEPTION 'manual release requires current double review' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_manual_release AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_manual_release();
|
||||
|
||||
CREATE FUNCTION protect_reviewed_bank_event_evidence() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE (evidence_id=OLD.id AND status IN ('approved','booked')) OR (correction_evidence_id=OLD.id AND status='dismissed')) THEN
|
||||
RAISE EXCEPTION 'reviewed bank event evidence retained' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER reviewed_bank_event_evidence BEFORE UPDATE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION protect_reviewed_bank_event_evidence();
|
||||
|
||||
CREATE FUNCTION check_loan_release_against_late_fact() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE bid UUID;
|
||||
BEGIN
|
||||
IF NEW.reservation_state<>'released' THEN RETURN NEW; END IF;
|
||||
SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id;
|
||||
IF EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status IN ('proposed','approved'))
|
||||
AND NOT EXISTS(SELECT 1 FROM loan_bank_result_events WHERE payment_batch_id=bid AND kind='late_success' AND status='booked') THEN
|
||||
RAISE EXCEPTION 'late success blocks original release conclusion' USING ERRCODE='23514'; END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE CONSTRAINT TRIGGER loan_release_late_fact AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_release_against_late_fact();
|
||||
@@ -750,6 +750,12 @@ model Employee {
|
||||
loan_recoveries_reviewed LoanReleaseRecovery[] @relation("LoanReleaseReviewer")
|
||||
loan_recoveries_consumed LoanReleaseRecovery[] @relation("LoanReleaseConsumer")
|
||||
loan_recovery_events LoanReleaseRecoveryEvent[]
|
||||
loan_not_submitted_proposals LoanNotSubmittedProposal[]
|
||||
loan_not_submitted_reviews LoanNotSubmittedReview[]
|
||||
loan_freezes_created LoanFreezeReason[] @relation("LoanFreezeCreator")
|
||||
loan_freezes_closed LoanFreezeReason[] @relation("LoanFreezeCloser")
|
||||
loan_bank_events_proposed LoanBankResultEvent[] @relation("LoanBankEventProposer")
|
||||
loan_bank_events_reviewed LoanBankResultEvent[] @relation("LoanBankEventReviewer")
|
||||
|
||||
}
|
||||
|
||||
@@ -1999,6 +2005,9 @@ model PaymentBatch {
|
||||
loan_failure_evidence_uses LoanFailureEvidenceUse[]
|
||||
@@map("payment_batches")
|
||||
loan_release_recoveries LoanReleaseRecovery[]
|
||||
loan_freeze_reasons LoanFreezeReason[]
|
||||
loan_bank_result_events LoanBankResultEvent[]
|
||||
loan_not_submitted_proposals LoanNotSubmittedProposal[]
|
||||
loan_material_revision LoanReleaseMaterialRevision?
|
||||
|
||||
}
|
||||
@@ -3897,6 +3906,7 @@ model LoanLender {
|
||||
bank_institution BankInstitution @relation(fields: [bank_institution_id], references: [id], onDelete: Restrict)
|
||||
loans CompanyLoan[]
|
||||
repayments LoanRepayment[]
|
||||
freeze_reasons LoanFreezeReason[]
|
||||
|
||||
@@unique([legal_entity_id, id])
|
||||
@@unique([created_by, request_key])
|
||||
@@ -3931,6 +3941,8 @@ model CompanyLoan {
|
||||
allocations LoanRepaymentAllocation[]
|
||||
evidences LoanEvidence[]
|
||||
|
||||
supplemental_settlements LoanSupplementalSettlement[]
|
||||
|
||||
@@unique([created_by, request_key])
|
||||
@@unique([legal_entity_id, lender_id, origin_reference])
|
||||
@@index([lender_id, confirmation_status, received_date])
|
||||
@@ -4125,6 +4137,7 @@ model LoanBankSubmissionAttestation {
|
||||
// Loan bank-result reconciliation S3 (migration 20261008010000_loan_bank_terminal_facts).
|
||||
// Evidence is stored apart from payment receipts; history triggers live in the migration.
|
||||
model LoanBankTerminalEvidence {
|
||||
evidence_kind String @default("bank_terminal") @db.VarChar(32)
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
storage_path String
|
||||
@@ -4141,6 +4154,8 @@ model LoanBankTerminalEvidence {
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
|
||||
uploader Employee @relation(fields: [uploaded_by], references: [id], onDelete: Restrict)
|
||||
facts LoanBankTerminalFact[]
|
||||
bank_result_events LoanBankResultEvent[] @relation("LoanBankEventEvidence")
|
||||
bank_result_corrections LoanBankResultEvent[] @relation("LoanBankEventCorrection")
|
||||
|
||||
@@index([payment_batch_id], map: "loan_bank_terminal_evidences_batch_idx")
|
||||
@@map("loan_bank_terminal_evidences")
|
||||
@@ -4244,3 +4259,107 @@ model LoanReleaseMaterialRevision {
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@map("loan_release_material_revisions")
|
||||
}
|
||||
|
||||
model LoanNotSubmittedProposal {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
sequence Int
|
||||
status String @default("proposed") @db.VarChar(16)
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
payload_json Json
|
||||
snapshot_json Json
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
proposer Employee @relation(fields: [proposed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviews LoanNotSubmittedReview[]
|
||||
@@unique([payment_batch_id, sequence])
|
||||
@@map("loan_not_submitted_proposals")
|
||||
}
|
||||
|
||||
model LoanNotSubmittedReview {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
proposal_id String @db.Uuid
|
||||
reviewed_by String @db.Uuid
|
||||
decision String @db.VarChar(16)
|
||||
reason String @db.VarChar(500)
|
||||
reviewed_at DateTime @default(now())
|
||||
proposal LoanNotSubmittedProposal @relation(fields: [proposal_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviewer Employee @relation(fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([proposal_id, reviewed_by])
|
||||
@@map("loan_not_submitted_reviews")
|
||||
}
|
||||
|
||||
model LoanFreezeReason {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
lender_id String @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
kind String @db.VarChar(32)
|
||||
source_key String @db.VarChar(160)
|
||||
status String @default("active") @db.VarChar(16)
|
||||
created_by String @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
closed_by String? @db.Uuid
|
||||
closed_at DateTime?
|
||||
closure_basis Json?
|
||||
lender LoanLender @relation(fields: [lender_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
creator Employee @relation("LoanFreezeCreator", fields: [created_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
closer Employee? @relation("LoanFreezeCloser", fields: [closed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([kind, source_key])
|
||||
@@index([lender_id, status])
|
||||
@@map("loan_freeze_reasons")
|
||||
}
|
||||
|
||||
model LoanBankResultEvent {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
payment_batch_id String @db.Uuid
|
||||
kind String @db.VarChar(16)
|
||||
parent_id String? @db.Uuid
|
||||
evidence_id String @db.Uuid
|
||||
request_key String @db.Uuid
|
||||
proposed_by String @db.Uuid
|
||||
proposed_at DateTime @default(now())
|
||||
payload_encrypted String
|
||||
fingerprint String @db.VarChar(64)
|
||||
key_id String @db.VarChar(64)
|
||||
amount Decimal @db.Decimal(14,2)
|
||||
snapshot_json Json
|
||||
status String @default("proposed") @db.VarChar(16)
|
||||
reviewed_by String? @db.Uuid
|
||||
reviewed_at DateTime?
|
||||
review_note String? @db.VarChar(1000)
|
||||
correction_evidence_id String? @db.Uuid
|
||||
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
evidence LoanBankTerminalEvidence @relation("LoanBankEventEvidence", fields: [evidence_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
correction_evidence LoanBankTerminalEvidence? @relation("LoanBankEventCorrection", fields: [correction_evidence_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
proposer Employee @relation("LoanBankEventProposer", fields: [proposed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
reviewer Employee? @relation("LoanBankEventReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
parent LoanBankResultEvent? @relation("LoanBankEventParent", fields: [parent_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
children LoanBankResultEvent[] @relation("LoanBankEventParent")
|
||||
settlements LoanSupplementalSettlement[]
|
||||
claim LoanBankEventClaim?
|
||||
@@unique([payment_batch_id, request_key])
|
||||
@@index([payment_batch_id, kind])
|
||||
@@map("loan_bank_result_events")
|
||||
}
|
||||
model LoanSupplementalSettlement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
event_id String @db.Uuid
|
||||
loan_id String @db.Uuid
|
||||
bank_amount Decimal @db.Decimal(14,2)
|
||||
settled_amount Decimal @db.Decimal(14,2)
|
||||
receivable_amount Decimal @db.Decimal(14,2)
|
||||
created_at DateTime @default(now())
|
||||
event LoanBankResultEvent @relation(fields: [event_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
loan CompanyLoan @relation(fields: [loan_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@unique([event_id, loan_id])
|
||||
@@map("loan_supplemental_settlements")
|
||||
}
|
||||
model LoanBankEventClaim {
|
||||
fingerprint String @id @db.VarChar(64)
|
||||
key_id String @db.VarChar(64)
|
||||
event_id String @unique @db.Uuid
|
||||
created_at DateTime @default(now())
|
||||
event LoanBankResultEvent @relation(fields: [event_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
|
||||
@@map("loan_bank_event_claims")
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
const ERROR_REASONS = [
|
||||
{"code": "loan.bank_result_reconcile_required", "domain": "loan", "audience": "user", "defaultMessage": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。", "recovery": "follow_guidance"},
|
||||
{"code": "loan.lender_frozen", "domain": "loan", "audience": "user", "defaultMessage": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。", "recovery": "follow_guidance"},
|
||||
{"code": "loan.not_submitted_invalid", "domain": "loan", "audience": "user", "defaultMessage": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。", "recovery": "follow_guidance"},
|
||||
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_unavailable","domain":"loan","audience":"user","defaultMessage":"当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。","recovery":"follow_guidance"},
|
||||
{"code":"loan.recovery_exists","domain":"loan","audience":"user","defaultMessage":"已有补证中或有效的恢复复核,请继续处理当前轮次。","recovery":"follow_guidance"},
|
||||
|
||||
@@ -149,6 +149,7 @@ function mapManualReceiptError(error) {
|
||||
const RECEIPT_FILE_QUERY_SCHEMA = z.object({
|
||||
mode: z.enum(["download", "preview"]).default("download"),
|
||||
reason: z.string().max(500).optional(),
|
||||
purpose: z.literal("loan_reconciliation").optional(),
|
||||
}).strict();
|
||||
|
||||
const HIGH_RISK_BATCH_THRESHOLD_AMOUNT = 50000;
|
||||
@@ -1054,7 +1055,15 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
|
||||
...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => {
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config),
|
||||
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch) };
|
||||
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch),
|
||||
notSubmitted: await require("../services/loanNotSubmitted").state(tx, batch),
|
||||
failureAttributions: await Promise.all((await tx.loanReceiptAttribution.findMany({ where: { payment_batch_id: batch.id, kind: "failure" }, orderBy: { proposed_at: "desc" } })).map(async (a) => ({
|
||||
id: a.id, status: a.status, proposedBy: a.proposed_by, basisNote: a.basis_note, exclusionNote: a.exclusion_note,
|
||||
coveredSubmissionIds: a.covered_submission_ids, failureScope: a.failure_scope,
|
||||
receiptAttachmentIds: [...new Set((await tx.paymentReceiptVerification.findMany({ where: { id: { in: a.verification_ids } }, select: { attachment_id: true } })).map((v) => v.attachment_id))],
|
||||
}))),
|
||||
bankResults: await require("../services/loanBankResults").state(tx, batch),
|
||||
freeze: await require("../services/loanFreezes").state(tx, repayment.lender_id) };
|
||||
})() } : {}),
|
||||
...mapBatch(batch, openRiskFlags, legacyPayees, {
|
||||
includePayee: true,
|
||||
@@ -1586,7 +1595,61 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.withdrawRecovery(tx, batch, request, id, reason));
|
||||
return sendData(reply, request, recoveryResponse(result));
|
||||
});
|
||||
const BANK_RESULT_SCHEMA = z.object({
|
||||
kind: z.enum(["late_success", "return"]), parentId: UUID_SCHEMA.optional(), evidenceId: UUID_SCHEMA, requestKey: UUID_SCHEMA,
|
||||
fields: z.array(z.object({ Name: z.string().max(50), Value: z.string().max(200) }).strict()).min(6).max(20),
|
||||
attributionNote: z.string().trim().min(10).max(1000), exclusionNote: z.string().trim().min(10).max(1000),
|
||||
}).strict();
|
||||
const BANK_RESULT_REVIEW_SCHEMA = z.object({ decision: z.enum(["approve", "reject"]), reason: z.string().trim().min(5).max(1000),
|
||||
correctionEvidenceId: UUID_SCHEMA.optional(), correctionKind: z.enum(["misassociated", "duplicate"]).optional() }).strict();
|
||||
fastify.get("/payment-batches/:id/loan-bank-results/:eventId/details", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
let reason = ""; try { reason = decodeURIComponent(String(request.headers["x-access-reason"] ?? "")); } catch { /* invalid reason */ }
|
||||
parseBody(z.object({ reason: z.string().trim().min(5).max(500) }).strict(), { reason });
|
||||
const event = await fastify.prisma.loanBankResultEvent.findUnique({ where: { id: parseId(request.params.eventId) } });
|
||||
if (!event || event.payment_batch_id !== parseId(request.params.id)) throw buildNotFoundError();
|
||||
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action: "LOAN_BANK_RESULT_DETAILS_VIEWED", targetType: "payment_batch", targetId: event.payment_batch_id, extra: { eventId: event.id, reason } }) });
|
||||
reply.header("cache-control", "no-store");
|
||||
return sendData(reply, request, JSON.parse(decryptStringOrThrow(fastify.config, event.payload_encrypted)).input);
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(BANK_RESULT_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").propose(tx, fastify.config, batch, request, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results/:eventId/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(BANK_RESULT_REVIEW_SCHEMA, request.body); const id = parseId(request.params.eventId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").review(tx, fastify.config, batch, request, id, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-bank-results/:eventId/apply", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
parseBody(z.object({}).strict(), request.body ?? {}); const id = parseId(request.params.eventId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanBankResults").apply(tx, fastify.config, batch, request, id));
|
||||
return sendData(reply, request, { id: result.id, status: result.status, conflict: Boolean(result.conflict) });
|
||||
});
|
||||
const NOT_SUBMITTED_SCHEMA = z.object({
|
||||
exportIds: z.array(UUID_SCHEMA).min(1).max(100), queryEvidenceId: UUID_SCHEMA, statementEvidenceId: UUID_SCHEMA,
|
||||
exporterStatements: z.array(z.object({ exporterId: UUID_SCHEMA, evidenceId: UUID_SCHEMA }).strict()).min(1).max(100),
|
||||
coverageFrom: z.string().datetime({ offset: true }), coverageThrough: z.string().datetime({ offset: true }),
|
||||
note: z.string().trim().min(10).max(1000),
|
||||
}).strict();
|
||||
fastify.post("/payment-batches/:id/loan-not-submitted", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(NOT_SUBMITTED_SCHEMA, request.body);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanNotSubmitted").propose(tx, fastify.config, batch, request, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.get("/payment-batches/:id/loan-not-submitted", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const batch = await loadBatchWithItems(fastify.prisma, parseId(request.params.id));
|
||||
if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
return sendData(reply, request, await require("../services/loanNotSubmitted").state(fastify.prisma, batch));
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-not-submitted/:proposalId/review", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const input = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
|
||||
const id = parseId(request.params.proposalId);
|
||||
const result = await attributionTransaction(request, (tx, batch) => require("../services/loanNotSubmitted").review(tx, fastify.config, batch, request, id, input));
|
||||
return sendData(reply, request, { id: result.id, status: result.status });
|
||||
});
|
||||
fastify.post("/payment-batches/:id/loan-terminal-evidences", { preHandler: authenticateBatch }, async (request, reply) => {
|
||||
const { kind: evidenceKind } = parseBody(z.object({ kind: z.enum(["bank_terminal", "query_no_result", "account_statement", "exporter_statement", "late_success", "incoming_return"]).default("bank_terminal") }).strict(), request.query);
|
||||
const files = [];
|
||||
try {
|
||||
for await (const part of request.parts({ limits: { files: 1, fields: 0, parts: 1, fileSize: 10 * 1024 * 1024 } })) {
|
||||
@@ -1604,7 +1667,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
const storagePath = await writeBuffer({ config: fastify.config, key: `loan-terminal-evidence/${parseId(request.params.id)}/${crypto.randomUUID()}${file.extension}`,
|
||||
buffer: file.buffer, contentType: file.mimeType });
|
||||
try {
|
||||
const result = await attributionTransaction(request, (tx, batch) => uploadTerminalEvidence(tx, batch, request, { ...file, storagePath }));
|
||||
const result = await attributionTransaction(request, (tx, batch) => uploadTerminalEvidence(tx, batch, request, { ...file, storagePath, evidenceKind }));
|
||||
return sendData(reply, request, { id: result.id });
|
||||
} catch (error) {
|
||||
try { await deleteObject({ config: fastify.config, storagePath }); } catch { request.log.error("loan terminal evidence cleanup failed"); }
|
||||
@@ -1993,7 +2056,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (!["reviewed", "failed"].includes(batch.status)) throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true });
|
||||
// No new payment order once failure / non-payment was concluded.
|
||||
if (["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
|
||||
if (["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
|
||||
downloadContext.purpose = "loan_principal_repayment";
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
}
|
||||
@@ -2220,7 +2283,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
// After a failure / not-paid conclusion a late receipt is still recorded
|
||||
// (state unchanged) so the final release re-check sees and blocks on it.
|
||||
if (!["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) {
|
||||
if (!["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) {
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
}
|
||||
}
|
||||
@@ -2326,6 +2389,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
original_name: true,
|
||||
mime_type: true,
|
||||
file_size: true,
|
||||
file_sha256: true,
|
||||
governance_status: true,
|
||||
sensitivity_level: true,
|
||||
attachment_type: true,
|
||||
@@ -2336,7 +2400,19 @@ async function paymentBatchesRoutes(fastify) {
|
||||
if (!attachment) {
|
||||
throw buildNotFoundError();
|
||||
}
|
||||
if (attachment.archived_at) {
|
||||
const reconciliationRead = queryParsed.data.purpose === "loan_reconciliation";
|
||||
if (reconciliationRead) {
|
||||
if (previewMode || batch.entity_type !== "loan_repayment" || !sanitizeDownloadReason(queryParsed.data.reason)) {
|
||||
throw buildBusinessValidationError("payment_batch.invalid_input", {}, 400);
|
||||
}
|
||||
const facts = await fastify.prisma.loanBankTerminalFact.findMany({ where: { payment_batch_id: batchId },
|
||||
select: { receipt_attachment_id: true, resolves_attachment_ids: true } });
|
||||
const attributions = await fastify.prisma.loanReceiptAttribution.findMany({ where: { payment_batch_id: batchId, kind: "failure" }, select: { verification_ids: true } });
|
||||
const linkedVerification = await fastify.prisma.paymentReceiptVerification.count({ where: {
|
||||
attachment_id: attachmentId, id: { in: attributions.flatMap((a) => a.verification_ids) } } });
|
||||
if (!linkedVerification && !facts.some((f) => f.receipt_attachment_id === attachmentId || f.resolves_attachment_ids.includes(attachmentId))) throw buildNotFoundError();
|
||||
}
|
||||
if (attachment.archived_at && !reconciliationRead) {
|
||||
throw buildBusinessValidationError("payment_batch.receipt_voided", { receiptAttachment: "voided" }, 409);
|
||||
}
|
||||
const downloadReason = previewMode
|
||||
@@ -2410,6 +2486,10 @@ async function paymentBatchesRoutes(fastify) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (reconciliationRead && (storedBuffer.length !== attachment.file_size || crypto.createHash("sha256").update(storedBuffer).digest("hex") !== attachment.file_sha256)) {
|
||||
throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
}
|
||||
|
||||
let validated;
|
||||
try {
|
||||
validated = await validateAndNormalizePurchaseAttachment({
|
||||
@@ -2459,6 +2539,7 @@ async function paymentBatchesRoutes(fastify) {
|
||||
sensitivityLevel: attachment.sensitivity_level ?? SENSITIVITY_LEVEL.confidential,
|
||||
governanceStatus: attachment.governance_status ?? "approved",
|
||||
reason: downloadReason,
|
||||
...(reconciliationRead ? { purpose: "loan_reconciliation", archived: Boolean(attachment.archived_at) } : {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -79,6 +79,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
|
||||
repayment = await readLoanRepayment(tx, batch);
|
||||
}
|
||||
const lender = repayment.lender;
|
||||
if (!postPayment) await require("./loanFreezes").assertNotFrozen(tx, lender.id);
|
||||
if (postPayment) assertLenderIdentityVerified(lender);
|
||||
else assertLenderReady(lender);
|
||||
if (lender.identity_version !== repayment.lender_identity_version || lender.account_version !== repayment.lender_account_version) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
|
||||
@@ -112,6 +113,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
|
||||
// waits on a previously existing batch. Lender/loan maintenance never locks a batch.
|
||||
async function prepareLoanRepayment(tx, config, request, input) {
|
||||
const lender = await lockLender(tx, input.lenderId);
|
||||
await require("./loanFreezes").assertNotFrozen(tx, lender.id);
|
||||
assertLenderReady(lender);
|
||||
if (lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
|
||||
const ids = input.allocations.map((a) => a.loanId).sort();
|
||||
@@ -225,7 +227,7 @@ async function assertReceiptAttributed(tx, config, batch, repayment) {
|
||||
|
||||
async function settleLoanRepayment(tx, batch, request, config) {
|
||||
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
if (["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
await assertReceiptAttributed(tx, config, batch, repayment);
|
||||
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
|
||||
@@ -239,10 +241,13 @@ async function settleLoanRepayment(tx, batch, request, config) {
|
||||
async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryInput = {}) {
|
||||
// Stale identity/account must not prevent cancelling a never-sent request.
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
if (!["not_sent", "confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) ||
|
||||
if (!["not_sent", "confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"].includes(repayment.bank_result_state) ||
|
||||
repayment.bank_result_state === "not_sent" && (batch.payment_order_export_count > 0 ||
|
||||
await tx.paymentBatchReceiptAttachment.count({ where: { payment_batch_id: batch.id } }) > 0)) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
|
||||
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
|
||||
if (await tx.loanBankResultEvent.count({ where: { payment_batch_id: batch.id, kind: "late_success", status: { in: ["proposed", "approved"] } } })) {
|
||||
throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409);
|
||||
}
|
||||
// "Never sent" must really mean nothing reached the bank side of the record.
|
||||
if (repayment.bank_result_state === "not_sent" && (
|
||||
await tx.loanBankSubmission.count({ where: { payment_batch_id: batch.id } }) ||
|
||||
@@ -251,7 +256,9 @@ async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryI
|
||||
// Release re-validates the conclusion inside this transaction (batch lock held
|
||||
// by the caller serializes it with new submissions and receipt uploads).
|
||||
const recovered = await require("./loanReleaseRecoveries").consumeRecovery(tx, config, batch, request, recoveryInput);
|
||||
if (!recovered && repayment.bank_result_state === "confirmed_not_paid") {
|
||||
if (!recovered && repayment.bank_result_state === "confirmed_not_submitted") {
|
||||
await require("./loanNotSubmitted").assertReleasable(tx, config, batch);
|
||||
} else if (!recovered && repayment.bank_result_state === "confirmed_not_paid") {
|
||||
await require("./loanBankTerminalFacts").assertNotPaidReleasable(tx, config, batch);
|
||||
} else if (!recovered && repayment.bank_result_state === "confirmed_failed") {
|
||||
const verificationId = String(repayment.bank_result_ref ?? "").replace(/^verification:/, "");
|
||||
|
||||
@@ -5,7 +5,7 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { decryptStringOrThrow, encryptStringOrThrow, maskBankAccount } = require("./crypto");
|
||||
|
||||
const LENDER_INCLUDE = { bank_institution: true, legal_entity: true };
|
||||
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true };
|
||||
const LOAN_INCLUDE = { lender: { include: LENDER_INCLUDE }, allocations: true, evidences: true, supplemental_settlements: true };
|
||||
|
||||
function ensureLoanManager(fastify, request) {
|
||||
if (!request.employee?.roleCodes?.some((role) => ["finance", "finance_mgr"].includes(role)) ||
|
||||
@@ -26,7 +26,7 @@ async function auditLoan(tx, request, action, targetId, extra = {}) {
|
||||
function loanBalances(loan) {
|
||||
const sum = (state) => (loan.allocations ?? []).filter((a) => a.reservation_state === state)
|
||||
.reduce((total, a) => total.plus(a.amount), new Prisma.Decimal(0));
|
||||
const settled = sum("settled");
|
||||
const settled = (loan.supplemental_settlements ?? []).reduce((total, row) => total.plus(row.settled_amount), sum("settled"));
|
||||
const reserved = sum("reserved");
|
||||
const outstanding = new Prisma.Decimal(loan.opening_outstanding_principal).minus(settled);
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
const { Prisma } = require("@prisma/client");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { encryptStringOrThrow, decryptStringOrThrow } = require("./crypto");
|
||||
const { normalizeBankSlip, matchBankSlip } = require("./paymentReceiptMatching");
|
||||
const { batchCandidates, transactionFingerprint } = require("./paymentReceiptVerification");
|
||||
const { auditLoan, loanBalances, LOAN_INCLUDE } = require("./companyLoans");
|
||||
const { digest } = require("./loanReleaseRecoveries");
|
||||
const payments = () => require("./companyLoanPayments");
|
||||
const freezes = () => require("./loanFreezes");
|
||||
const fail = () => { throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409); };
|
||||
const TERMINAL = ["confirmed_failed", "confirmed_not_paid", "confirmed_not_submitted"];
|
||||
|
||||
async function source(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
if (!TERMINAL.includes(repayment.bank_result_state) || !["reviewed", "failed", "cancelled"].includes(batch.status)) fail();
|
||||
// Bank facts use the frozen approval and account snapshots, not current
|
||||
// employment or subsequently maintained lender/bank master data.
|
||||
if (!batch.reviewed_by || !batch.reviewed_at || [batch.created_by, repayment.submitted_by].includes(batch.reviewed_by) ||
|
||||
repayment.legal_entity_id !== batch.legal_entity_id || !repayment.amount.eq(batch.items[0].amount)) fail();
|
||||
return repayment;
|
||||
}
|
||||
async function evidence(tx, config, batch, id, kind) {
|
||||
const file = await tx.loanBankTerminalEvidence.findUnique({ where: { id } });
|
||||
if (!file || file.payment_batch_id !== batch.id || file.voided_at || file.evidence_kind !== kind) fail();
|
||||
const { openReadStream } = require("./storage");
|
||||
try {
|
||||
const opened = await openReadStream({ config, storagePath: file.storage_path }); const hash = createHash("sha256"); let size = 0;
|
||||
for await (const chunk of opened.stream) { hash.update(chunk); size += chunk.length; }
|
||||
if (size !== file.file_size || hash.digest("hex") !== file.file_sha256) fail();
|
||||
} catch { fail(); }
|
||||
return file;
|
||||
}
|
||||
async function participants(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
const base = await require("./loanBankTerminalFacts").terminalParticipants(tx, batch, repayment);
|
||||
const [events, manual, q6, sr] = await Promise.all([
|
||||
tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } } }),
|
||||
tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
tx.loanReleaseRecovery.findMany({ where: { payment_batch_id: batch.id } }),
|
||||
]);
|
||||
return [...new Set([...base, ...events.map((e) => e.proposed_by), ...manual.map((p) => p.proposed_by), ...q6.map((p) => p.proposed_by), ...sr.map((p) => p.started_by)].filter(Boolean))];
|
||||
}
|
||||
async function bankSnapshot(tx, batch, file) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
return { evidenceId: file.id, sha: file.file_sha256, kind: file.evidence_kind,
|
||||
payment: digest({ payer: batch.payment_account_snapshot_json, items: batch.items.map((i) => ({ id: i.id, snapshot: i.snapshot_json, amount: String(i.amount) })) }),
|
||||
allocations: repayment.allocations.map((a) => ({ id: a.id, loanId: a.loan_id, amount: String(a.amount) })),
|
||||
competitors: await payments().competingLoanBatches(tx, batch, repayment) };
|
||||
}
|
||||
async function assertTransactionFree(tx, config, event) {
|
||||
const page = JSON.parse(decryptStringOrThrow(config, event.payload_encrypted)).page;
|
||||
const identity = transactionFingerprint(config, page);
|
||||
if (identity.fingerprint !== event.fingerprint || identity.key_id !== event.key_id) fail();
|
||||
const [claims, aliases, events] = await Promise.all([
|
||||
tx.paymentReceiptClaim.count({ where: { OR: [{ fingerprint: event.fingerprint }, { key_id: { not: event.key_id } }] } }),
|
||||
tx.paymentReceiptTransactionAlias.count({ where: { OR: [{ fingerprint: event.fingerprint }, { key_id: { not: event.key_id } }] } }),
|
||||
tx.loanBankEventClaim.count({ where: { OR: [{ fingerprint: event.fingerprint, event_id: { not: event.id } }, { key_id: { not: event.key_id } }] } }),
|
||||
]);
|
||||
if (claims || aliases || events) fail();
|
||||
}
|
||||
async function outstandingReturn(tx, id) {
|
||||
const [settlements, returned] = await Promise.all([
|
||||
tx.loanSupplementalSettlement.findMany({ where: { event_id: id } }),
|
||||
tx.loanBankResultEvent.findMany({ where: { parent_id: id, kind: "return", status: "booked" } }),
|
||||
]);
|
||||
return settlements.reduce((sum, s) => sum.plus(s.receivable_amount), new Prisma.Decimal(0))
|
||||
.minus(returned.reduce((sum, r) => sum.plus(r.amount), new Prisma.Decimal(0)));
|
||||
}
|
||||
async function propose(tx, config, batch, request, input) {
|
||||
const existing = await tx.loanBankResultEvent.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
|
||||
if (existing) {
|
||||
const saved = JSON.parse(decryptStringOrThrow(config, existing.payload_encrypted));
|
||||
if (existing.proposed_by !== request.employee.id || digest(saved.input) !== digest(input)) fail();
|
||||
return existing;
|
||||
}
|
||||
await source(tx, batch);
|
||||
const file = await evidence(tx, config, batch, input.evidenceId, input.kind === "return" ? "incoming_return" : "late_success");
|
||||
const page = normalizeBankSlip(input.fields);
|
||||
if (page.issues.length || page.transactionStatus !== "success") fail();
|
||||
const [candidate] = batchCandidates(config, batch);
|
||||
const amount = new Prisma.Decimal(page.amount).div(100);
|
||||
if (!amount.gt(0)) fail();
|
||||
if (input.kind === "late_success") {
|
||||
if (input.parentId || matchBankSlip(page, [candidate]).status !== "matched") fail();
|
||||
} else {
|
||||
const parent = input.parentId && await tx.loanBankResultEvent.findUnique({ where: { id: input.parentId } });
|
||||
if (!parent || parent.payment_batch_id !== batch.id || parent.kind !== "late_success" || parent.status !== "booked" ||
|
||||
matchBankSlip(page, [{ ...candidate, amount: amount.toFixed(2), payerAccount: candidate.payeeAccount, payeeAccount: candidate.payerAccount }]).status !== "matched" ||
|
||||
amount.gt(await outstandingReturn(tx, parent.id))) fail();
|
||||
}
|
||||
const identity = transactionFingerprint(config, page);
|
||||
const previous = await tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id, fingerprint: identity.fingerprint,
|
||||
key_id: identity.key_id, kind: input.kind, parent_id: input.parentId ?? null, status: { in: ["proposed", "approved"] } }, orderBy: [{ proposed_at: "desc" }, { id: "desc" }] });
|
||||
const event = await tx.loanBankResultEvent.create({ data: { payment_batch_id: batch.id, kind: input.kind,
|
||||
parent_id: input.parentId ?? null, evidence_id: file.id, request_key: input.requestKey, proposed_by: request.employee.id,
|
||||
payload_encrypted: encryptStringOrThrow(config, JSON.stringify({ page, input })), amount,
|
||||
fingerprint: identity.fingerprint, key_id: identity.key_id, snapshot_json: await bankSnapshot(tx, batch, file) } });
|
||||
if (input.kind === "late_success") await freezes().openReason(tx, batch, request, "late_success", event.id);
|
||||
// Claims are consumed only at final booking, never just by approval. A new
|
||||
// freeze exists before a stale proposed/approved entry is superseded.
|
||||
for (const old of previous) {
|
||||
await tx.loanBankResultEvent.update({ where: { id: old.id }, data: { status: "superseded" } });
|
||||
if (old.kind === "late_success") {
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: old.id } } });
|
||||
if (conflict?.status === "active") {
|
||||
await freezes().openReason(tx, batch, request, "reservation_conflict", event.id);
|
||||
await freezes().closeReason(tx, batch, request, "reservation_conflict", old.id, { action: "evidence_replaced", recordId: event.id });
|
||||
}
|
||||
await freezes().closeReason(tx, batch, request, "late_success", old.id, { action: "evidence_replaced", recordId: event.id });
|
||||
}
|
||||
}
|
||||
|
||||
await auditLoan(tx, request, "LOAN_BANK_RESULT_PROPOSED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: event.id, kind: event.kind });
|
||||
return event;
|
||||
}
|
||||
async function eventForBatch(tx, batch, id) {
|
||||
const event = await tx.loanBankResultEvent.findUnique({ where: { id } });
|
||||
if (!event || event.payment_batch_id !== batch.id) fail();
|
||||
return event;
|
||||
}
|
||||
async function review(tx, config, batch, request, id, input) {
|
||||
const event = await eventForBatch(tx, batch, id);
|
||||
if (event.status !== "proposed") fail();
|
||||
await source(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch), { lock: true });
|
||||
if (input.decision === "approve") {
|
||||
const file = await evidence(tx, config, batch, event.evidence_id, event.kind === "return" ? "incoming_return" : "late_success");
|
||||
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
|
||||
await assertTransactionFree(tx, config, event);
|
||||
if (event.kind === "return" && event.amount.gt(await outstandingReturn(tx, event.parent_id))) fail();
|
||||
} else if (event.kind === "late_success") {
|
||||
// Uncertainty is not a dismissal. A correction document and a reviewed
|
||||
// specific reason are mandatory before removing a false-alarm freeze.
|
||||
if (!input.correctionEvidenceId || !["misassociated", "duplicate"].includes(input.correctionKind)) fail();
|
||||
await evidence(tx, config, batch, input.correctionEvidenceId, "bank_terminal");
|
||||
}
|
||||
const status = input.decision === "approve" ? "approved" : event.kind === "late_success" ? "dismissed" : "rejected";
|
||||
const updated = await tx.loanBankResultEvent.update({ where: { id }, data: { status,
|
||||
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason, correction_evidence_id: input.correctionEvidenceId ?? null } });
|
||||
if (status === "dismissed") {
|
||||
const basis = { action: input.correctionKind, recordId: id, evidenceId: input.correctionEvidenceId };
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: id } } });
|
||||
if (conflict?.status === "active") await freezes().closeReason(tx, batch, request, "reservation_conflict", id, basis);
|
||||
await freezes().closeReason(tx, batch, request, "late_success", id, basis);
|
||||
}
|
||||
await auditLoan(tx, request, "LOAN_BANK_RESULT_REVIEWED", batch.loan_repayment_id, { paymentBatchId: batch.id, eventId: id, status });
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Finance applies an already independent bank fact. No exportable object is
|
||||
// created. Original bank outcome stays terminal and the event supersedes it.
|
||||
async function apply(tx, config, batch, request, id) {
|
||||
const event = await eventForBatch(tx, batch, id);
|
||||
if (event.status === "booked") return event;
|
||||
if (event.status !== "approved") fail();
|
||||
const repayment = await source(tx, batch);
|
||||
const file = await evidence(tx, config, batch, event.evidence_id, event.kind === "return" ? "incoming_return" : "late_success");
|
||||
if (digest(event.snapshot_json) !== digest(await bankSnapshot(tx, batch, file))) fail();
|
||||
await assertTransactionFree(tx, config, event);
|
||||
if ((await participants(tx, batch)).includes(event.reviewed_by)) fail();
|
||||
const ids = repayment.allocations.map((a) => a.loan_id).sort();
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM company_loans WHERE id IN (${Prisma.join(ids)}) ORDER BY id FOR UPDATE`);
|
||||
if (event.kind === "return") {
|
||||
if (event.amount.gt(await outstandingReturn(tx, event.parent_id))) fail();
|
||||
await tx.loanBankEventClaim.create({ data: { fingerprint: event.fingerprint, key_id: event.key_id, event_id: id } });
|
||||
const booked = await tx.loanBankResultEvent.update({ where: { id }, data: { status: "booked" } });
|
||||
if ((await outstandingReturn(tx, event.parent_id)).eq(0)) await freezes().closeReason(tx, batch, request, "receivable", event.parent_id, { action: "returned_in_full", recordId: id });
|
||||
await auditLoan(tx, request, "LOAN_RETURN_BOOKED", repayment.id, { paymentBatchId: batch.id, eventId: id, parentId: event.parent_id, amount: event.amount.toFixed(2) });
|
||||
return booked;
|
||||
}
|
||||
const conflicts = await tx.loanRepaymentAllocation.findMany({ where: { loan_id: { in: ids }, repayment_id: { not: repayment.id }, reservation_state: "reserved" } });
|
||||
if (conflicts.length) {
|
||||
await freezes().openReason(tx, batch, request, "reservation_conflict", id);
|
||||
await auditLoan(tx, request, "LOAN_LATE_SUCCESS_CONFLICT", repayment.id, { eventId: id, allocationIds: conflicts.map((a) => a.id) });
|
||||
return { ...event, conflict: true };
|
||||
}
|
||||
if (repayment.allocations.some((a) => !["reserved", "released"].includes(a.reservation_state))) fail();
|
||||
if (repayment.allocations.some((a) => a.reservation_state === "reserved")) {
|
||||
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
|
||||
data: { reservation_state: "released", released_at: new Date(), release_reason: "原结论被晚到成功推翻,改由补记结清" } });
|
||||
await tx.paymentBatchItem.updateMany({ where: { payment_batch_id: batch.id, status: { in: ["pending", "failed"] } }, data: { status: "cancelled" } });
|
||||
await tx.paymentBatch.update({ where: { id: batch.id }, data: { status: "cancelled", cancelled_at: new Date(),
|
||||
cancelled_by: request.employee.id, cancelled_reason: "原结论被晚到成功推翻,改由补记结清", updated_by: request.employee.id } });
|
||||
}
|
||||
await tx.loanBankEventClaim.create({ data: { fingerprint: event.fingerprint, key_id: event.key_id, event_id: id } });
|
||||
let receivable = new Prisma.Decimal(0);
|
||||
for (const allocation of repayment.allocations) {
|
||||
const loan = await tx.companyLoan.findUniqueOrThrow({ where: { id: allocation.loan_id }, include: LOAN_INCLUDE });
|
||||
const outstanding = new Prisma.Decimal(loanBalances(loan).outstanding);
|
||||
const settled = Prisma.Decimal.min(allocation.amount, outstanding);
|
||||
const excess = allocation.amount.minus(settled); receivable = receivable.plus(excess);
|
||||
await tx.loanSupplementalSettlement.create({ data: { event_id: id, loan_id: loan.id,
|
||||
bank_amount: allocation.amount, settled_amount: settled, receivable_amount: excess } });
|
||||
}
|
||||
const booked = await tx.loanBankResultEvent.update({ where: { id }, data: { status: "booked" } });
|
||||
if (receivable.gt(0)) await freezes().openReason(tx, batch, request, "receivable", id);
|
||||
const conflict = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind: "reservation_conflict", source_key: id } } });
|
||||
if (conflict?.status === "active") await freezes().closeReason(tx, batch, request, "reservation_conflict", id, { action: "allocations_resolved", recordId: id });
|
||||
await freezes().closeReason(tx, batch, request, "late_success", id, { action: "payment_supplemented", recordId: id });
|
||||
await auditLoan(tx, request, "LOAN_LATE_SUCCESS_BOOKED", repayment.id, { paymentBatchId: batch.id, eventId: id,
|
||||
originalState: repayment.bank_result_state, originalRef: repayment.bank_result_ref, bankAmount: event.amount.toFixed(2), receivable: receivable.toFixed(2) });
|
||||
return booked;
|
||||
}
|
||||
async function state(tx, batch) {
|
||||
const events = await tx.loanBankResultEvent.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ proposed_at: "asc" }, { id: "asc" }], include: { settlements: true } });
|
||||
return { events: await Promise.all(events.map(async (e) => ({ id: e.id, kind: e.kind, parentId: e.parent_id, evidenceId: e.evidence_id,
|
||||
status: e.status, amount: e.amount.toFixed(2), proposedBy: e.proposed_by, reviewedBy: e.reviewed_by, reviewNote: e.review_note,
|
||||
outstandingReturn: e.kind === "late_success" && e.status === "booked" ? (await outstandingReturn(tx, e.id)).toFixed(2) : null,
|
||||
settlements: e.settlements.map((s) => ({ loanId: s.loan_id, bankAmount: s.bank_amount.toFixed(2), settledAmount: s.settled_amount.toFixed(2), receivableAmount: s.receivable_amount.toFixed(2) })) }))) };
|
||||
}
|
||||
module.exports = { propose, review, apply, state, source };
|
||||
@@ -114,7 +114,7 @@ async function activeAttestation(tx, batch) {
|
||||
async function proposeSubmissionAttestation(tx, batch, request, note) {
|
||||
assertSubmissionBatch(batch);
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_required", {}, 409);
|
||||
if (!facts.active.length && (!facts.exports.length || facts.submissions.length)) throw buildBusinessValidationError("loan.submission_required", {}, 409);
|
||||
const active = await activeAttestation(tx, batch);
|
||||
if (active) {
|
||||
if (attestationMatches(active, facts)) throw buildBusinessValidationError("loan.submission_attestation_exists", {}, 409);
|
||||
|
||||
@@ -59,7 +59,7 @@ async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
|
||||
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
if (fact.kind === "termination_evidence") {
|
||||
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: fact.evidence_id } });
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at || evidence.evidence_kind !== "bank_terminal") throw buildBusinessValidationError("loan.terminal_fact_invalid", {}, 409);
|
||||
} else {
|
||||
let slip;
|
||||
try { slip = await failureSlip(tx, config, batch, fact.receipt_attachment_id); }
|
||||
@@ -178,12 +178,12 @@ function releaseWaitDays(config) {
|
||||
|
||||
// The full "not paid" release condition, evaluated under the batch lock both
|
||||
// when confirming and again in the final cancel transaction.
|
||||
async function assertNotPaidReleasable(tx, config, batch) {
|
||||
async function assertNotPaidReleasable(tx, config, batch, { manualExportIds = [] } = {}) {
|
||||
const { submissionFacts, attestationMatches, voidedAttachmentResolved } = submissions();
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409);
|
||||
const approvedList = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
if (!facts.active.length || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
if ((!facts.active.length && !manualExportIds.length) || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
|
||||
const terminal = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
|
||||
const covered = new Set();
|
||||
const usedFacts = [];
|
||||
@@ -196,7 +196,7 @@ async function assertNotPaidReleasable(tx, config, batch) {
|
||||
usedFacts.push(fact.id);
|
||||
}
|
||||
// Every export must be accounted for by a submission that reached a terminal state.
|
||||
const linkedExports = new Set(facts.active.map((item) => item.export_id).filter(Boolean));
|
||||
const linkedExports = new Set([...facts.active.map((item) => item.export_id).filter(Boolean), ...manualExportIds]);
|
||||
if (facts.exports.some((item) => !linkedExports.has(item.id))) throw buildBusinessValidationError("loan.export_unlinked", {}, 409);
|
||||
// No payment receipt may stay unexplained: covered by a terminal fact, or a
|
||||
// voided slip that is provably a failure or a clearly different payment.
|
||||
@@ -240,22 +240,23 @@ async function terminalState(tx, batch) {
|
||||
tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ proposed_at: "asc" }, { id: "asc" }] }),
|
||||
]);
|
||||
return {
|
||||
evidences: evidences.map((item) => ({ id: item.id, originalName: item.original_name, uploadedAt: item.uploaded_at, voided: Boolean(item.voided_at) })),
|
||||
evidences: evidences.map((item) => ({ id: item.id, originalName: item.original_name, evidenceKind: item.evidence_kind, uploadedAt: item.uploaded_at, voided: Boolean(item.voided_at) })),
|
||||
facts: terminal.map((item) => ({ id: item.id, submissionId: item.submission_id, kind: item.kind, terminalStatus: item.terminal_status,
|
||||
evidenceId: item.evidence_id, receiptAttachmentId: item.receipt_attachment_id, resolvesAttachmentIds: item.resolves_attachment_ids,
|
||||
status: item.status, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })),
|
||||
status: item.status, note: item.note, proposedBy: item.proposed_by, reviewedBy: item.reviewed_by })),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadTerminalEvidence(tx, batch, request, file) {
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
|
||||
if (batch.status === "cancelled") await require("./loanBankResults").source(tx, batch);
|
||||
else assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
|
||||
// The same bank letter cannot back terminal facts of two batches.
|
||||
if (await tx.loanBankTerminalEvidence.count({ where: { file_sha256: file.fileSha256, payment_batch_id: { not: batch.id } } })) {
|
||||
throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
}
|
||||
const evidence = await tx.loanBankTerminalEvidence.create({ data: {
|
||||
payment_batch_id: batch.id, storage_path: file.storagePath, original_name: file.originalName, mime_type: file.mimeType,
|
||||
payment_batch_id: batch.id, evidence_kind: file.evidenceKind ?? "bank_terminal", storage_path: file.storagePath, original_name: file.originalName, mime_type: file.mimeType,
|
||||
file_size: file.fileSize, file_sha256: file.fileSha256, uploaded_by: request.employee.id } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_UPLOADED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id });
|
||||
return evidence;
|
||||
@@ -268,6 +269,7 @@ async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reas
|
||||
assertTerminalBatch(batch, await loanPayments().readLoanRepayment(tx, batch));
|
||||
const evidence = await tx.loanBankTerminalEvidence.findUnique({ where: { id: evidenceId } });
|
||||
if (!evidence || evidence.payment_batch_id !== batch.id || evidence.voided_at) throw buildBusinessValidationError("loan.terminal_evidence_invalid", {}, 409);
|
||||
if (await tx.loanBankResultEvent.count({ where: { OR: [{ evidence_id: evidence.id, status: { in: ["approved", "booked"] } }, { correction_evidence_id: evidence.id, status: "dismissed" }] } })) throw buildBusinessValidationError("loan.bank_result_reconcile_required", {}, 409);
|
||||
const voided = await tx.loanBankTerminalEvidence.update({ where: { id: evidence.id }, data: {
|
||||
voided_at: new Date(), voided_by: request.employee.id, void_reason: reason } });
|
||||
await auditLoan(tx, request, "LOAN_TERMINAL_EVIDENCE_VOIDED", batch.loan_repayment_id, { paymentBatchId: batch.id, evidenceId: evidence.id });
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { lockLender, auditLoan } = require("./companyLoans");
|
||||
|
||||
// Call under the lender lock for all pre-payment actions. Bank facts never use
|
||||
// this gate. A closed cause cannot mask another active cause.
|
||||
async function assertNotFrozen(tx, lenderId) {
|
||||
if (await tx.loanFreezeReason.count({ where: { lender_id: lenderId, status: "active" } })) {
|
||||
throw buildBusinessValidationError("loan.lender_frozen", {}, 409);
|
||||
}
|
||||
}
|
||||
async function openReason(tx, batch, request, kind, sourceKey) {
|
||||
const repayment = await require("./companyLoanPayments").readLoanRepayment(tx, batch);
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
const existing = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind, source_key: sourceKey } } });
|
||||
if (existing) {
|
||||
if (existing.payment_batch_id !== batch.id) throw buildBusinessValidationError("loan.invalid_input", {}, 409);
|
||||
return existing;
|
||||
}
|
||||
const reason = await tx.loanFreezeReason.create({ data: { lender_id: repayment.lender_id, payment_batch_id: batch.id,
|
||||
kind, source_key: sourceKey, created_by: request.employee.id } });
|
||||
await auditLoan(tx, request, "LOAN_FREEZE_OPENED", repayment.id, { paymentBatchId: batch.id, freezeId: reason.id, kind });
|
||||
return reason;
|
||||
}
|
||||
|
||||
// Internal only: S6/S7 call after resolving their source fact in this same
|
||||
// transaction. Never expose a generic close/unfreeze route to users.
|
||||
async function closeReason(tx, batch, request, kind, sourceKey, basis) {
|
||||
const repayment = await require("./companyLoanPayments").readLoanRepayment(tx, batch);
|
||||
await lockLender(tx, repayment.lender_id);
|
||||
const reason = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind, source_key: sourceKey } } });
|
||||
if (!reason || reason.payment_batch_id !== batch.id || !basis?.action || !basis?.recordId) {
|
||||
throw buildBusinessValidationError("loan.invalid_input", {}, 409);
|
||||
}
|
||||
if (reason.status === "closed") return reason;
|
||||
const closed = await tx.loanFreezeReason.update({ where: { id: reason.id }, data: { status: "closed",
|
||||
closed_by: request.employee.id, closed_at: new Date(), closure_basis: basis } });
|
||||
await auditLoan(tx, request, "LOAN_FREEZE_CLOSED", repayment.id, { paymentBatchId: batch.id, freezeId: reason.id, kind, basis });
|
||||
return closed;
|
||||
}
|
||||
async function state(tx, lenderId) {
|
||||
const reasons = await tx.loanFreezeReason.findMany({ where: { lender_id: lenderId }, orderBy: [{ created_at: "asc" }, { id: "asc" }] });
|
||||
return { frozen: reasons.some((r) => r.status === "active"), reasons: reasons.map((r) => ({ id: r.id, kind: r.kind,
|
||||
sourceKey: r.source_key, paymentBatchId: r.payment_batch_id, status: r.status, closureBasis: r.closure_basis })) };
|
||||
}
|
||||
module.exports = { assertNotFrozen, openReason, closeReason, state };
|
||||
@@ -0,0 +1,130 @@
|
||||
const { createHash } = require("node:crypto");
|
||||
const { buildBusinessValidationError } = require("../utils/businessValidationError");
|
||||
const { auditLoan } = require("./companyLoans");
|
||||
const payments = () => require("./companyLoanPayments");
|
||||
const terminal = () => require("./loanBankTerminalFacts");
|
||||
const { snapshot, digest } = require("./loanReleaseRecoveries");
|
||||
const fail = () => { throw buildBusinessValidationError("loan.not_submitted_invalid", {}, 409); };
|
||||
const latest = (tx, batch) => tx.loanNotSubmittedProposal.findFirst({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "desc" }, include: { reviews: true } });
|
||||
|
||||
async function materialSnapshot(tx, batch) {
|
||||
const value = await snapshot(tx, batch);
|
||||
// The conclusion changes only after the second review. It is not evidence.
|
||||
delete value.originalState; delete value.originalRef;
|
||||
return value;
|
||||
}
|
||||
async function unchanged(tx, batch, proposal) {
|
||||
return digest(proposal.snapshot_json) === digest(await materialSnapshot(tx, batch));
|
||||
}
|
||||
async function available(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status) ||
|
||||
!["unknown", "confirmed_not_submitted"].includes(repayment.bank_result_state) ||
|
||||
!repayment.allocations.length || repayment.allocations.some((a) => a.reservation_state !== "reserved")) fail();
|
||||
return repayment;
|
||||
}
|
||||
async function participants(tx, batch) {
|
||||
const repayment = await payments().readLoanRepayment(tx, batch);
|
||||
const proposals = await tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id } });
|
||||
const manual = await tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } } });
|
||||
return [...new Set([...(await terminal().terminalParticipants(tx, batch, repayment)),
|
||||
...proposals.map((p) => p.proposed_by), ...manual.map((p) => p.proposed_by)].filter(Boolean))];
|
||||
}
|
||||
|
||||
// All three documentary categories, all unlinked exports, and all exporters.
|
||||
// Dates are asserted by the registrar and independently reviewed, not OCR proof.
|
||||
async function assertEvidence(tx, config, batch, input) {
|
||||
await available(tx, batch);
|
||||
await payments().validateLoanPayment(tx, batch, { lock: true, postPayment: true });
|
||||
const { submissionFacts } = require("./loanBankSubmissions");
|
||||
const facts = await submissionFacts(tx, batch);
|
||||
if (facts.legacy || !facts.exports.length) fail();
|
||||
const linked = new Set(facts.active.map((s) => s.export_id).filter(Boolean));
|
||||
const unlinked = facts.exports.filter((e) => !linked.has(e.id));
|
||||
if (!unlinked.length || digest([...input.exportIds].sort()) !== digest(unlinked.map((e) => e.id).sort())) fail();
|
||||
// A withdrawn submission is not proof that the bank did not receive it.
|
||||
if (facts.submissions.some((s) => s.status !== "active" && (!s.export_id || input.exportIds.includes(s.export_id)))) fail();
|
||||
const first = Math.min(...facts.exports.map((e) => new Date(e.exported_at).getTime()));
|
||||
const from = new Date(input.coverageFrom).getTime(); const through = new Date(input.coverageThrough).getTime();
|
||||
if (!Number.isFinite(from) || !Number.isFinite(through) || from > first || through < new Date(input.registeredAt).getTime() || through > Date.now()) fail();
|
||||
const required = [
|
||||
[input.queryEvidenceId, "query_no_result"], [input.statementEvidenceId, "account_statement"],
|
||||
...input.exporterStatements.map((s) => [s.evidenceId, "exporter_statement"]),
|
||||
];
|
||||
const exporters = [...new Set(unlinked.map((e) => e.exported_by))].sort();
|
||||
if (digest(exporters) !== digest(input.exporterStatements.map((s) => s.exporterId).sort()) ||
|
||||
new Set(required.map(([id]) => id)).size !== required.length) fail();
|
||||
const { openReadStream } = require("./storage");
|
||||
for (const [id, kind] of required) {
|
||||
const file = await tx.loanBankTerminalEvidence.findUnique({ where: { id } });
|
||||
if (!file || file.payment_batch_id !== batch.id || file.voided_at || file.evidence_kind !== kind ||
|
||||
(kind === "query_no_result" && file.mime_type !== "application/pdf")) fail();
|
||||
try {
|
||||
const opened = await openReadStream({ config, storagePath: file.storage_path });
|
||||
const hash = createHash("sha256"); let bytes = 0;
|
||||
for await (const chunk of opened.stream) { hash.update(chunk); bytes += chunk.length; }
|
||||
if (bytes !== file.file_size || hash.digest("hex") !== file.file_sha256) fail();
|
||||
} catch { fail(); }
|
||||
}
|
||||
// S3 still validates every real submission, every receipt, the approved list
|
||||
// and the wait period. Only these named exports use the manual exception.
|
||||
await terminal().assertNotPaidReleasable(tx, config, batch, { manualExportIds: input.exportIds });
|
||||
}
|
||||
async function propose(tx, config, batch, request, input) {
|
||||
await available(tx, batch);
|
||||
const previous = await latest(tx, batch);
|
||||
if (previous && ["proposed", "approved"].includes(previous.status)) {
|
||||
if (await unchanged(tx, batch, previous)) fail();
|
||||
await tx.loanNotSubmittedProposal.update({ where: { id: previous.id }, data: { status: "superseded" } });
|
||||
}
|
||||
// The coverage end must reach registration (Asia/Shanghai calendar day), without asking
|
||||
// the client to predict the server's millisecond clock.
|
||||
const registeredAt = new Date(Date.now() + 8 * 60 * 60 * 1000).toISOString().slice(0, 10) + "T00:00:00.000+08:00";
|
||||
const payload = { ...input, registeredAt };
|
||||
await assertEvidence(tx, config, batch, payload);
|
||||
const proposal = await tx.loanNotSubmittedProposal.create({ data: { payment_batch_id: batch.id,
|
||||
sequence: (previous?.sequence ?? 0) + 1, proposed_by: request.employee.id,
|
||||
payload_json: payload, snapshot_json: await materialSnapshot(tx, batch) } });
|
||||
await auditLoan(tx, request, "LOAN_NOT_SUBMITTED_PROPOSED", batch.loan_repayment_id, { paymentBatchId: batch.id, proposalId: proposal.id });
|
||||
return proposal;
|
||||
}
|
||||
async function review(tx, config, batch, request, id, input) {
|
||||
const proposal = await latest(tx, batch);
|
||||
if (!proposal || proposal.id !== id || proposal.status !== "proposed") fail();
|
||||
const repayment = await available(tx, batch);
|
||||
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch), { lock: true });
|
||||
if (proposal.reviews.some((r) => r.reviewed_by === request.employee.id)) fail();
|
||||
if (input.decision === "approve") {
|
||||
if (!await unchanged(tx, batch, proposal)) fail();
|
||||
await assertEvidence(tx, config, batch, proposal.payload_json);
|
||||
}
|
||||
await tx.loanNotSubmittedReview.create({ data: { proposal_id: id, reviewed_by: request.employee.id,
|
||||
decision: input.decision, reason: input.reason } });
|
||||
let status = "proposed";
|
||||
if (input.decision === "reject") status = "rejected";
|
||||
else if (proposal.reviews.length === 1 && proposal.reviews[0].decision === "approve") status = "approved";
|
||||
if (status !== "proposed") await tx.loanNotSubmittedProposal.update({ where: { id }, data: { status } });
|
||||
if (status === "approved" && repayment.bank_result_state === "unknown") {
|
||||
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
|
||||
bank_result_state: "confirmed_not_submitted", bank_result_ref: `not-submitted:${id}` } });
|
||||
}
|
||||
await auditLoan(tx, request, "LOAN_NOT_SUBMITTED_REVIEWED", batch.loan_repayment_id,
|
||||
{ paymentBatchId: batch.id, proposalId: id, decision: input.decision, status });
|
||||
return { id, status };
|
||||
}
|
||||
async function assertReleasable(tx, config, batch) {
|
||||
const proposal = await latest(tx, batch);
|
||||
if (!proposal || proposal.status !== "approved" || proposal.reviews.length !== 2 ||
|
||||
proposal.reviews.some((r) => r.decision !== "approve") ||
|
||||
new Set(proposal.reviews.map((r) => r.reviewed_by)).size !== 2 || !await unchanged(tx, batch, proposal)) fail();
|
||||
const excluded = await participants(tx, batch);
|
||||
if (proposal.reviews.some((r) => excluded.includes(r.reviewed_by))) fail();
|
||||
await assertEvidence(tx, config, batch, proposal.payload_json);
|
||||
}
|
||||
async function state(tx, batch) {
|
||||
const rows = await tx.loanNotSubmittedProposal.findMany({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "desc" }, include: { reviews: true } });
|
||||
return { history: await Promise.all(rows.map(async (p) => ({ id: p.id, sequence: p.sequence, status: p.status,
|
||||
proposedBy: p.proposed_by, payload: p.payload_json, stale: ["proposed", "approved"].includes(p.status) && !await unchanged(tx, batch, p),
|
||||
reviews: p.reviews.map((r) => ({ reviewedBy: r.reviewed_by, decision: r.decision, reason: r.reason })) }))) };
|
||||
}
|
||||
module.exports = { propose, review, state, assertReleasable };
|
||||
@@ -205,5 +205,5 @@ async function recoveryState(tx, batch) {
|
||||
submittedBy: r.submitted_by, reviewedBy: r.reviewed_by, reviewNote: r.review_note, originalState: r.original_state })) };
|
||||
}
|
||||
|
||||
module.exports = { startRecovery, submitRecovery, reviewRecovery, withdrawRecovery, consumeRecovery,
|
||||
module.exports = { snapshot, digest, startRecovery, submitRecovery, reviewRecovery, withdrawRecovery, consumeRecovery,
|
||||
isRecoveryCancelRetry, recoveryState, assertRecoveryCollecting, latestRecovery };
|
||||
|
||||
@@ -139,6 +139,7 @@ async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {
|
||||
requireFact(new Set(options.map((option) => JSON.stringify(option.aliases))).size === 1, "receipt_multiple_transactions");
|
||||
const match = options[0];
|
||||
requireFact(match.key_id === keyId, "receipt_key_rotation_required");
|
||||
requireFact(await tx.loanBankEventClaim.count({ where: { OR: [{ fingerprint: { in: match.aliases } }, { key_id: { not: keyId } }] } }) === 0, "receipt_transaction_reused");
|
||||
const existing = await tx.paymentReceiptClaim.findMany({
|
||||
where: { OR: [{ fingerprint: { in: match.aliases } }, { item_id: item.id }] },
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"loan.bank_result_reconcile_required": "银行对账事实、归属或证据尚不满足条件,请补齐材料并独立复核;占用或冻结继续保留。",
|
||||
"loan.lender_frozen": "该出借人存在尚未处理的银行对账事项,暂停新还款、复核和导出;仍可登记银行事实并对账。",
|
||||
"loan.not_submitted_invalid": "人工未提交的证据、清单或双人复核不满足要求,请补齐后重新登记复核。",
|
||||
"loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。",
|
||||
"loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。",
|
||||
"loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。",
|
||||
|
||||
@@ -284,6 +284,7 @@ function buildPrisma(options = {}) {
|
||||
|
||||
const tx = {
|
||||
businessNumberPolicy: { findUnique: vi.fn(async () => null) },
|
||||
loanBankEventClaim: { count: vi.fn().mockResolvedValue(0) },
|
||||
paymentReceiptClaim: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
|
||||
paymentReceiptTransactionAlias: { count: vi.fn(async () => 0), findMany: vi.fn(async () => []), create: vi.fn() },
|
||||
paymentReceiptManualProposal: { findMany: vi.fn(async () => []) },
|
||||
|
||||
@@ -22,6 +22,7 @@ function fixture() {
|
||||
const tx = {
|
||||
$queryRaw: vi.fn().mockResolvedValue([{ id: attachment.id }]),
|
||||
paymentReceiptVerification: { findFirst: vi.fn().mockResolvedValue(verification) },
|
||||
loanBankEventClaim: { count: vi.fn().mockResolvedValue(0) },
|
||||
paymentReceiptClaim: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() },
|
||||
paymentReceiptTransactionAlias: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]), create: vi.fn() },
|
||||
paymentReceiptManualProposal: { findMany: vi.fn().mockResolvedValue([]) },
|
||||
@@ -30,6 +31,12 @@ function fixture() {
|
||||
}
|
||||
|
||||
describe("receipt evidence and transaction claims", () => {
|
||||
test("bank transactions already used for supplements or returns cannot be claimed again", async () => {
|
||||
const { tx, batch } = fixture();
|
||||
tx.loanBankEventClaim.count.mockResolvedValue(1);
|
||||
await expect(claimVerifiedReceipts(tx, config, batch)).rejects.toMatchObject({ code: "receipt_transaction_reused" });
|
||||
expect(tx.paymentReceiptClaim.create).not.toHaveBeenCalled();
|
||||
});
|
||||
test("mixed historical cash items do not require bank snapshots or bank receipt claims", async () => {
|
||||
const { batch, attachment, recognition, tx } = fixture();
|
||||
batch.items.push({ id: "cash-item", status: "pending", amount: "10.00", currency_code: "CNY",
|
||||
|
||||
在新工单中引用
屏蔽一个用户