支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)

公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。
仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。
贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。
授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。

本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。
已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。
按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。

限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。
生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
这个提交包含在:
2026-10-08 15:35:33 +08:00
父节点 4c37fceeb4
当前提交 ce11dea976
共修改 39 个文件,包含 2336 行新增和 208 行删除
+10 -3
查看文件
@@ -2,6 +2,7 @@ const { createHash } = require("node:crypto");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow } = require("./crypto");
const { auditLoan } = require("./companyLoans");
const approvalPolicy = require("./loanApprovalPolicy");
// Loan bank-result reconciliation S1 (design.md 3.1, 3.3; implement.md S1).
// Payment-order exports and bank submissions are separate facts: one export
@@ -17,6 +18,7 @@ function loanPayments() {
}
async function recordLoanPaymentOrderExport(tx, batch, request, generated) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const previous = await tx.paymentOrderExport.count({ where: { payment_batch_id: batch.id } });
return tx.paymentOrderExport.create({ data: {
payment_batch_id: batch.id, sequence: previous + 1, exported_by: request.employee.id,
@@ -32,6 +34,7 @@ function assertSubmissionBatch(batch) {
}
async function registerBankSubmission(tx, batch, request, input) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
assertSubmissionBatch(batch);
if (!SUBMISSION_CHANNELS.includes(input.channel)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
if (input.exportId) {
@@ -67,6 +70,7 @@ async function registerBankSubmission(tx, batch, request, input) {
}
async function withdrawBankSubmission(tx, batch, request, submissionId, reason) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
assertSubmissionBatch(batch);
const submission = await tx.loanBankSubmission.findUnique({ where: { id: submissionId } });
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") {
@@ -112,6 +116,7 @@ async function activeAttestation(tx, batch) {
// A stale open/approved attestation (lists changed) is superseded in the same
// transaction so the batch can always move forward.
async function proposeSubmissionAttestation(tx, batch, request, note) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
assertSubmissionBatch(batch);
const facts = await submissionFacts(tx, batch);
if (!facts.active.length && (!facts.exports.length || facts.submissions.length)) throw buildBusinessValidationError("loan.submission_required", {}, 409);
@@ -140,16 +145,17 @@ async function reviewSubmissionAttestation(tx, batch, request, attestationId, in
const { readLoanRepayment, activeReviewer } = loanPayments();
const repayment = await readLoanRepayment(tx, batch);
const facts = await submissionFacts(tx, batch);
const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, repayment.lender.employee_id,
const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment),
batch.payment_order_last_exported_by, facts.legacy?.last_exported_by,
...facts.exports.map((item) => item.exported_by), ...facts.submissions.map((item) => item.registered_by)];
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true, batch });
if (input.decision === "approve" && !attestationMatches(attestation, facts)) throw buildBusinessValidationError("loan.version_changed", {}, 409);
const reviewed = await tx.loanBankSubmissionAttestation.update({ where: { id: attestation.id }, data: {
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: attestation.id, conflictIds: excluded }),
status: input.decision === "approve" ? "approved" : "rejected", reviewed_by: request.employee.id,
reviewed_at: new Date(), review_note: input.reason } });
await auditLoan(tx, request, input.decision === "approve" ? "LOAN_SUBMISSION_ATTESTATION_APPROVED" : "LOAN_SUBMISSION_ATTESTATION_REJECTED",
batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id });
batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id, ...approvalPolicy.auditContext(reviewed) });
return reviewed;
}
@@ -163,6 +169,7 @@ async function assertSingleAttestedSubmission(tx, batch) {
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
const approved = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
if (!attestationMatches(approved, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
await approvalPolicy.assertDecision(tx, batch, approved);
const [submission] = facts.active;
// The single export (if any) must be the one this submission imported.
if (facts.exports.length && submission.export_id !== facts.exports[0].id) throw buildBusinessValidationError("loan.export_unlinked", {}, 409);