支持财务经办与公司代表关联方还款确认(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 行删除
+17 -6
查看文件
@@ -1,6 +1,7 @@
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow } = require("./crypto");
const { auditLoan } = require("./companyLoans");
const approvalPolicy = require("./loanApprovalPolicy");
// Loan bank-result reconciliation S3 (design.md 3.2; implement.md S3).
// A repayment is released as "not paid" only when every reviewed bank submission
@@ -56,6 +57,7 @@ async function failureSlip(tx, config, batch, attachmentId) {
// reason when it no longer holds (submission withdrawn, evidence voided, the
// failed slip re-recognized or voided, a resolved pending slip changed).
async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
if (fact.status === "approved") await approvalPolicy.assertDecision(tx, batch, fact);
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 } });
@@ -76,6 +78,7 @@ async function assertTerminalFactHolds(tx, config, batch, fact, submission) {
const approved = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, kind: "failure", failure_scope: "submission",
status: "approved", covered_submission_ids: { equals: [submission.id] } } });
if (!approved || !attributionMatches(approved, slip, competitors, [submission.id])) throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
await approvalPolicy.assertDecision(tx, batch, approved);
}
}
for (const attachmentId of fact.resolves_attachment_ids ?? []) {
@@ -92,6 +95,7 @@ function assertTerminalBatch(batch, repayment) {
}
async function proposeTerminalFact(tx, config, batch, request, input) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const { readLoanRepayment } = loanPayments();
const repayment = await readLoanRepayment(tx, batch);
assertTerminalBatch(batch, repayment);
@@ -131,7 +135,7 @@ async function terminalParticipants(tx, batch, repayment) {
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } }),
]);
const receiptUploaders = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, select: { uploaded_by: true } });
return [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, ...(await reconciliationParticipants(tx, batch)),
return [batch.created_by, repayment.submitted_by, ...approvalPolicy.payeeConflicts(repayment), ...(await reconciliationParticipants(tx, batch)),
...facts.map((item) => item.proposed_by), ...evidences.map((item) => item.uploaded_by), ...receiptUploaders.map((item) => item.uploaded_by)].filter(Boolean);
}
@@ -141,7 +145,8 @@ async function reviewTerminalFact(tx, config, batch, request, factId, input) {
if (!fact || fact.payment_batch_id !== batch.id || fact.status !== "proposed") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
const repayment = await readLoanRepayment(tx, batch);
assertTerminalBatch(batch, repayment);
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, repayment), { lock: true });
const excluded = await terminalParticipants(tx, batch, repayment);
await activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
if (input.decision === "approve") {
const submission = await tx.loanBankSubmission.findUnique({ where: { id: fact.submission_id } });
await assertTerminalFactHolds(tx, config, batch, fact, submission);
@@ -150,9 +155,10 @@ async function reviewTerminalFact(tx, config, batch, request, factId, input) {
}
}
const reviewed = await tx.loanBankTerminalFact.update({ where: { id: fact.id }, data: {
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: fact.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_TERMINAL_FACT_APPROVED" : "LOAN_TERMINAL_FACT_REJECTED",
batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id });
batch.loan_repayment_id, { paymentBatchId: batch.id, factId: fact.id, ...approvalPolicy.auditContext(reviewed) });
return reviewed;
}
@@ -184,6 +190,7 @@ async function assertNotPaidReleasable(tx, config, batch, { manualExportIds = []
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 && !manualExportIds.length) || !attestationMatches(approvedList, facts)) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
await approvalPolicy.assertDecision(tx, batch, approvedList);
const terminal = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
const covered = new Set();
const usedFacts = [];
@@ -227,11 +234,13 @@ async function confirmNotPaid(tx, config, batch, request) {
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
assertTerminalBatch(batch, repayment);
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
await activeReviewer(tx, request.employee.id, await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch)), { lock: true });
const excluded = await terminalParticipants(tx, batch, await readLoanRepayment(tx, batch));
await activeReviewer(tx, request.employee.id, excluded, { lock: true, batch });
const usedFacts = await assertNotPaidReleasable(tx, config, batch);
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
const decided = await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: repayment.id, conflictIds: excluded }),
bank_result_state: "confirmed_not_paid", bank_result_ref: `terminal-facts:${usedFacts.sort().join(",")}` } });
await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts });
await auditLoan(tx, request, "LOAN_NOT_PAID_CONFIRMED", repayment.id, { paymentBatchId: batch.id, factIds: usedFacts, ...approvalPolicy.auditContext(decided) });
}
async function terminalState(tx, batch) {
@@ -248,6 +257,7 @@ async function terminalState(tx, batch) {
}
async function uploadTerminalEvidence(tx, batch, request, file) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
const { readLoanRepayment } = loanPayments();
if (batch.status === "cancelled") await require("./loanBankResults").source(tx, batch);
else assertTerminalBatch(batch, await readLoanRepayment(tx, batch));
@@ -264,6 +274,7 @@ async function uploadTerminalEvidence(tx, batch, request, file) {
// Voiding evidence invalidates every fact that relied on it.
async function voidTerminalEvidence(tx, config, batch, request, evidenceId, reason) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
// After release the evidence is part of the closed record; later facts belong
// to the late-result reconciliation flow, not to rewriting this one.
assertTerminalBatch(batch, await loanPayments().readLoanRepayment(tx, batch));