修复在途还款被出借人维护卡死并按审批时资格登记付款后事实(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]>
这个提交包含在:
@@ -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"},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户