支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)
公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。 仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。 贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。 授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。 本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。 已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。 按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。 限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。 生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
这个提交包含在:
@@ -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));
|
||||
|
||||
在新工单中引用
屏蔽一个用户