修复在途还款被出借人维护卡死并按审批时资格登记付款后事实(09-17-company-loan-principal-repayment)

- A:出借人存在 reserved 额度占用时,身份关联与收款资料维护在出借人锁内
  返回 409 loan.lender_in_flight;页面禁用并提示,修正账户弹窗误导文案
- B:回单 OCR 认领、人工回单参与人、核销按审批时资格认定,不再因原审批人
  或出借员工事后离职/撤权、主数据停用而卡死;保留身份核实、版本与付款快照
  一致、审批独立性及回单匹配校验;付款前环节仍校验当前资格
- 测试:隔离 PostgreSQL 合成全流程,含确定性并发交错、付款后独立性反例、
  离职后经 OCR/人工复核核销、PC/手机按钮禁用;变异测试均变红
- 恢复手册:资金类数据恢复后先暂停付款,对照银行流水补齐并核对占用再开放
- design 按环节修订资格规则

注意:C(同金额回单传错批次错误核销)已复现但尚未修复,借款功能不具备
真实还款验收条件;未推送、未部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-06 13:13:30 +08:00
共同撰写人 Claude Opus 5.5
父节点 1989f666ac
当前提交 b639422211
共修改 15 个文件,包含 311 行新增和 38 行删除
+1
查看文件
@@ -17,6 +17,7 @@ const ERROR_REASONS = [
{"code":"loan.bank_state_unsafe","domain":"loan","audience":"user","defaultMessage":"银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。","recovery":"follow_guidance"},
{"code":"loan.handoff_not_ready","domain":"loan","audience":"user","defaultMessage":"本金付款尚未成功登记,或已完成会计交接,请核对付款状态。","recovery":"follow_guidance"},
{"code":"loan.payment_not_ready","domain":"loan","audience":"user","defaultMessage":"还款申请或付款状态已变化,请刷新核对后继续。","recovery":"follow_guidance"},
{"code":"loan.lender_in_flight","domain":"loan","audience":"user","defaultMessage":"该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。","recovery":"follow_guidance"},
{"code":"config.enabled_query_invalid","domain":"config","audience":"user","defaultMessage":"启用状态筛选无效,请重新选择后查询。","recovery":"follow_guidance"},
{"code":"config.document_type_invalid","domain":"config","audience":"user","defaultMessage":"单据类型标识不符合要求,请重新选择单据类型。","recovery":"follow_guidance"},
{"code":"document_sequence.type_unsupported","domain":"document_sequence","audience":"user","defaultMessage":"当前单据类型不支持编号,请联系管理员核对。","recovery":"follow_guidance"},
+1 -1
查看文件
@@ -80,7 +80,7 @@ async function claimReceiptJob(prisma) {
return null;
}
if (batch.entity_type === "loan_repayment") {
try { await require("../services/companyLoanPayments").validateLoanPayment(tx, batch); }
try { await require("../services/companyLoanPayments").validateLoanPayment(tx, batch, { postPayment: true }); }
catch (error) {
if (!error?.details?.reasonCode?.startsWith("loan.")) throw error;
await tx.paymentBatchReceiptAttachment.updateMany({
+5 -1
查看文件
@@ -9,6 +9,7 @@ const {
LENDER_INCLUDE, LOAN_INCLUDE, ensureLoanManager, loanNotFound, auditLoan,
readLenderAccount, encryptLenderAccount, mapLender, mapLoan, mapEvidence, lockLoan, lockLender,
assertEvidenceComplete, assertCurrentVersion, validateLenderReferences, createIdempotently,
assertLenderNotInFlight, inFlightLenderIds,
} = require("../services/companyLoans");
const { writeBuffer, openReadStream, deleteObject, StorageNotFoundError } = require("../services/storage");
const { validateAndNormalizePurchaseAttachment, PurchaseAttachmentPolicyError, buildAttachmentContentDisposition } = require("../services/purchaseAttachmentPolicy");
@@ -83,7 +84,8 @@ async function companyLoansRoutes(fastify) {
skip: (query.page - 1) * query.pageSize, take: query.pageSize }),
fastify.prisma.loanLender.count({ where }),
]);
return sendData(reply, request, { items: items.map((item) => mapLender(fastify.config, item)), total });
const inFlight = await inFlightLenderIds(fastify.prisma, items.map((item) => item.id));
return sendData(reply, request, { items: items.map((item) => mapLender(fastify.config, item, { inFlight: inFlight.has(item.id) })), total });
});
fastify.post("/loan-lenders", auth, async (request, reply) => {
@@ -115,6 +117,7 @@ async function companyLoansRoutes(fastify) {
const item = await fastify.prisma.$transaction(async (tx) => {
const lender = await lockLender(tx, id);
assertCurrentVersion(lender.identity_version, body.expectedVersion);
await assertLenderNotInFlight(tx, id);
if (body.employeeId && !await tx.employee.findFirst({ where: { id: body.employeeId, status: "active", archived_at: null } })) {
throw buildBusinessValidationError("loan.source_unavailable", {}, 409);
}
@@ -138,6 +141,7 @@ async function companyLoansRoutes(fastify) {
const item = await fastify.prisma.$transaction(async (tx) => {
const lender = await lockLender(tx, id);
assertCurrentVersion(lender.account_version, body.expectedVersion);
await assertLenderNotInFlight(tx, id);
await validateLenderReferences(tx, { ...body, legalEntityId: lender.legal_entity_id });
const result = await tx.loanLender.update({ where: { id }, data: {
bank_institution_id: body.bankInstitutionId, bank_name: body.bankName, ...encryptLenderAccount(fastify.config, body),
+29 -7
查看文件
@@ -14,8 +14,12 @@ const REPAYMENT_INCLUDE = {
};
function assertLenderReady(lender) {
function assertLenderIdentityVerified(lender) {
if (lender?.identity_status !== "verified" || !lender.identity_confirmed_at || !lender.identity_confirmed_by) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
}
function assertLenderReady(lender) {
assertLenderIdentityVerified(lender);
if (lender.archived_at || !lender.legal_entity?.enabled || lender.legal_entity.archived_at ||
!lender.bank_institution?.enabled || lender.bank_institution.archived_at || lender.bank_institution.code !== "CCB") { throw buildBusinessValidationError("loan.source_unavailable", {}, 409); };
}
@@ -52,14 +56,31 @@ async function readLoanRepayment(tx, batch) {
return repayment;
}
async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, recheckApproval = false } = {}) {
// Approval-time eligibility for facts that already happened at the bank.
// The review route ran activeReviewer() against current roles when it set
// reviewed_by/reviewed_at, so a later departure or role change of that reviewer
// (or of the lender's linked employee) must not strand a paid repayment.
// Independence is still checked against the frozen participants.
function assertApprovedIndependently(batch, excludedIds) {
if (!batch.reviewed_by || !batch.reviewed_at || excludedIds.includes(batch.reviewed_by)) {
throw buildBusinessValidationError("loan.independent_review_required", {}, 403);
}
}
// postPayment=true is for receipt OCR, manual receipt review and settlement:
// identity/account versions and the frozen payee snapshot must still match
// (maintenance is blocked while reserved, see assertLenderNotInFlight), but
// current employment, roles and master-data enablement are not re-judged.
// Pre-payment paths (creation, review, payment-order export) keep current checks.
async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null, recheckApproval = false, postPayment = false } = {}) {
let repayment = await readLoanRepayment(tx, batch);
if (lock) {
await lockLender(tx, repayment.lender_id);
repayment = await readLoanRepayment(tx, batch);
}
const lender = repayment.lender;
assertLenderReady(lender);
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); };
if (repayment.legal_entity_id !== batch.legal_entity_id || repayment.currency_code !== "CNY" ||
!new Prisma.Decimal(repayment.amount).eq(batch.items[0].amount) || batch.items[0].currency_code !== "CNY" ||
@@ -68,7 +89,8 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
batch.items[0].snapshot_json?.payee?.accountNameEncrypted !== lender.account_name_encrypted) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
const excluded = [batch.created_by, repayment.submitted_by, lender.employee_id].filter(Boolean);
if (reviewerId) await activeReviewer(tx, reviewerId, excluded, { lock });
if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock });
if (postPayment) assertApprovedIndependently(batch, excluded);
else if (recheckApproval) await activeReviewer(tx, batch.reviewed_by, excluded, { lock });
if (lock) {
const ids = repayment.allocations.map((a) => a.loan_id).sort();
if (!ids.length) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); };
@@ -81,7 +103,7 @@ async function validateLoanPayment(tx, batch, { lock = false, reviewerId = null,
assertEvidenceComplete(loan);
if (new Prisma.Decimal(loanBalances(loan).available).lt(0)) { throw buildBusinessValidationError("loan.allocation_invalid", {}, 409); };
}
if (lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
if (!postPayment && lender.employee_id && !await tx.employee.findFirst({ where: { id: lender.employee_id, status: "active", archived_at: null } })) { throw buildBusinessValidationError("loan.identity_required", {}, 409); };
return repayment;
}
@@ -148,7 +170,7 @@ function repaymentMatchesInput(repayment, input) {
}
async function settleLoanRepayment(tx, batch, request) {
const repayment = await validateLoanPayment(tx, batch, { lock: true, recheckApproval: true });
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
if (repayment.bank_result_state === "confirmed_failed") { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
@@ -175,7 +197,7 @@ async function cancelLoanRepayment(tx, batch, request, reason) {
}
async function loanReceiptParticipants(tx, batch) {
const repayment = await validateLoanPayment(tx, batch);
const repayment = await validateLoanPayment(tx, batch, { postPayment: true });
return [{ ownerId: repayment.lender.employee_id, externalOwnerVerified: repayment.lender.employee_id === null,
submitterId: repayment.submitted_by }];
}
+22 -2
查看文件
@@ -57,7 +57,7 @@ function encryptLenderAccount(config, input) {
}
}
function mapLender(config, lender) {
function mapLender(config, lender, { inFlight = false } = {}) {
let bankAccountMasked = null; let accountUnavailable = false;
try { bankAccountMasked = maskBankAccount(readLenderAccount(config, lender).bankAccount); }
catch { accountUnavailable = true; }
@@ -66,7 +66,7 @@ function mapLender(config, lender) {
legalEntityName: lender.legal_entity?.name ?? null, employeeId: lender.employee_id,
identityStatus: lender.identity_status, identityVersion: lender.identity_version,
accountVersion: lender.account_version, bankInstitutionId: lender.bank_institution_id,
bankName: lender.bank_name, bankAccountMasked, accountUnavailable,
bankName: lender.bank_name, bankAccountMasked, accountUnavailable, hasInFlightRepayment: inFlight,
};
}
@@ -105,6 +105,25 @@ async function lockLender(tx, id) {
return tx.loanLender.findUniqueOrThrow({ where: { id }, include: LENDER_INCLUDE });
}
// Any reserved allocation means a repayment froze this lender's identity and
// account versions. Creation locks the lender before reserving, so checking
// under the same lender lock serializes with it; later batch steps never add
// reservations. Changing the lender then would strand an already-paid request.
async function assertLenderNotInFlight(tx, lenderId) {
const reserved = await tx.loanRepaymentAllocation.count({ where: {
reservation_state: "reserved", repayment: { lender_id: lenderId },
} });
if (reserved > 0) throw buildBusinessValidationError("loan.lender_in_flight", {}, 409);
}
async function inFlightLenderIds(db, lenderIds) {
if (!lenderIds.length) return new Set();
const rows = await db.loanRepayment.findMany({ where: {
lender_id: { in: lenderIds }, allocations: { some: { reservation_state: "reserved" } },
}, select: { lender_id: true }, distinct: ["lender_id"] });
return new Set(rows.map((row) => row.lender_id));
}
function assertEvidenceComplete(loan) {
const kinds = new Set((loan.evidences ?? []).filter((e) => !e.archived_at && e.governance_status === "approved").map((e) => e.kind));
const required = ["agreement", "incoming", "accounting_confirmation"];
@@ -146,4 +165,5 @@ module.exports = {
LENDER_INCLUDE, LOAN_INCLUDE, ensureLoanManager, loanNotFound, auditLoan, loanBalances,
readLenderAccount, encryptLenderAccount, mapLender, mapLoan, mapEvidence, lockLoan, lockLender,
assertEvidenceComplete, assertCurrentVersion, validateLenderReferences, createIdempotently,
assertLenderNotInFlight, inFlightLenderIds,
};