借款对账 S3:银行终态确认与释放(09-17-company-loan-principal-repayment)
迁移 20261008010000 - 银行终态证据单独成表(不进 OCR、不改付款状态、不计回单),只能作废一次, 作废即令依赖事实失效;复核人可凭理由查看(审计、SHA-256 校验) - 每次银行提交一条终态事实:书面终态证据,或流水号等于指令号的严格失败回单; 同一指令的终态可了结该指令的处理中回单,其他提交不能代替 - 新终态 confirmed_not_paid;释放须清单已复核未变、每次提交有成立的终态事实、 导出均关联提交、回单均有解释、等待期满、无历史汇总 - 最终取消在同一事务内重验(与新提交、晚到回单串行);两条释放路径都检查等待期 - 防挪用:指令号跨批次唯一(含撤回)、失败回单绑定本批次指令、一笔失败交易 只支撑一个批次、同一书面证据不能用于两个批次、登记提交即离开“未发送” 验证:借款 HTTP 57 组、浏览器 7 组、77 迁移;server 4472;四轮同模型只读复核 阻断项均已修复,资金守卫变异测试均变红。 注意:界面未完成,不能单独部署;真实回单待验;补证后的恢复缺口尚未设计。 未推送、未部署。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
@@ -40,11 +40,27 @@ async function registerBankSubmission(tx, batch, request, input) {
|
||||
} else if (input.channel === "online_banking_import") {
|
||||
throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409);
|
||||
}
|
||||
// Stored normalized so terminal evidence and slips can be bound to it; a
|
||||
// bank instruction may belong to only one loan batch.
|
||||
const { normalizeInstructionRef } = require("./loanBankTerminalFacts");
|
||||
const instructionRef = normalizeInstructionRef(input.bankInstructionRef);
|
||||
if (instructionRef.length < 2) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
|
||||
// Same batch: no second active row with this reference. Other batches: no row
|
||||
// in any status, so a withdrawn instruction cannot be re-registered elsewhere.
|
||||
if (await tx.loanBankSubmission.count({ where: { bank_instruction_ref: instructionRef,
|
||||
OR: [{ payment_batch_id: batch.id, status: "active" }, { payment_batch_id: { not: batch.id } }] } })) {
|
||||
throw buildBusinessValidationError("loan.instruction_ref_conflict", {}, 409);
|
||||
}
|
||||
const submission = await tx.loanBankSubmission.create({ data: {
|
||||
payment_batch_id: batch.id, export_id: input.exportId ?? null, channel: input.channel,
|
||||
submitted_at: new Date(input.submittedAt), bank_instruction_ref: input.bankInstructionRef,
|
||||
submitted_at: new Date(input.submittedAt), bank_instruction_ref: instructionRef,
|
||||
customer_serial: input.customerSerial ?? null, note: input.note, registered_by: request.employee.id,
|
||||
} });
|
||||
// A registered submission means the instruction may have reached the bank:
|
||||
// the repayment can no longer be treated as never sent.
|
||||
const { readLoanRepayment } = loanPayments();
|
||||
const repayment = await readLoanRepayment(tx, batch);
|
||||
if (repayment.bank_result_state === "not_sent") await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
|
||||
await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_REGISTERED", batch.loan_repayment_id, {
|
||||
paymentBatchId: batch.id, submissionId: submission.id, channel: input.channel });
|
||||
return submission;
|
||||
@@ -148,9 +164,8 @@ async function assertSingleAttestedSubmission(tx, batch) {
|
||||
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);
|
||||
const [submission] = facts.active;
|
||||
if (facts.exports.length && submission.export_id && submission.export_id !== facts.exports[0].id) {
|
||||
throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409);
|
||||
}
|
||||
// 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);
|
||||
return facts.activeIds;
|
||||
}
|
||||
|
||||
@@ -162,45 +177,50 @@ async function assertSingleAttestedSubmission(tx, batch) {
|
||||
// or a live manual-receipt proposal keeps blocking.
|
||||
const UNSETTLED_STATUS_ISSUES = ["transaction_pending", "transactionStatus_ambiguous", "transactionStatus_unresolved"];
|
||||
|
||||
async function assertVoidedReceiptsResolved(tx, config, batch) {
|
||||
// Per-attachment form of the voided-receipt rule (no database access): the
|
||||
// attachment must include its verifications (newest first) and manual proposals.
|
||||
function voidedAttachmentResolved(config, batch, attachment) {
|
||||
const { batchCandidates } = require("./paymentReceiptVerification");
|
||||
const { matchBankSlip } = require("./paymentReceiptMatching");
|
||||
const { isClearBankFailure } = loanPayments();
|
||||
if (attachment.manual_proposals.some((proposal) => proposal.status !== "rejected")) return false;
|
||||
if (attachment.verifications.some((verification) => verification.status === "matched")) return false;
|
||||
const latest = attachment.verifications[0];
|
||||
if (!latest || latest.source_sha256 !== attachment.file_sha256 || latest.source_version !== attachment.receipt_ocr_version) return false;
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, latest.normalized_encrypted));
|
||||
const pages = recognition.pages ?? [];
|
||||
if (!["recognized", "needs_review"].includes(recognition.status) || !(recognition.pagesExpected > 0) ||
|
||||
recognition.pagesProcessed !== recognition.pagesExpected || pages.length !== recognition.pagesExpected) return false;
|
||||
const candidates = batchCandidates(config, batch);
|
||||
const digits = (value) => String(value ?? "").replace(/\D/g, "");
|
||||
for (const page of pages) {
|
||||
if ((page.issues ?? []).some((issue) => UNSETTLED_STATUS_ISSUES.includes(issue))) return false;
|
||||
// A failed page counts only under the same strict rule as failure confirmation,
|
||||
// applied page by page: a failed page proves no payment on that page, so a
|
||||
// multi-page voided slip passes only if every page is a clear failure or a
|
||||
// readable, clearly different payment.
|
||||
if (page.transactionStatus === "failed") {
|
||||
if (isClearBankFailure({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [{ ...page, pageNumber: 1 }] })) continue;
|
||||
return false;
|
||||
}
|
||||
// Any other page must be fully readable (the recognizer reports no issue).
|
||||
if ((page.issues ?? []).length) return false;
|
||||
if (page.transactionStatus !== "success" || page.amount == null || !(Number(page.amount) > 0) || !page.payerAccount || !page.payeeAccount) return false;
|
||||
// "Clearly another payment": a definite mismatch AND at least one account
|
||||
// differs from every candidate. Same accounts with a different amount may
|
||||
// be a misread of this very payment, so it keeps blocking.
|
||||
if (matchBankSlip({ ...page, issues: [] }, candidates).status !== "mismatch") return false;
|
||||
if (candidates.some((candidate) => digits(candidate.payerAccount) === digits(page.payerAccount) &&
|
||||
digits(candidate.payeeAccount) === digits(page.payeeAccount))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function assertVoidedReceiptsResolved(tx, config, batch) {
|
||||
const voided = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id, archived_at: { not: null } },
|
||||
include: { verifications: { orderBy: [{ created_at: "desc" }, { id: "desc" }] }, manual_proposals: { select: { status: true } } } });
|
||||
const unresolved = () => buildBusinessValidationError("loan.voided_receipt_unresolved", {}, 409);
|
||||
for (const attachment of voided) {
|
||||
if (attachment.manual_proposals.some((proposal) => proposal.status !== "rejected")) throw unresolved();
|
||||
if (attachment.verifications.some((verification) => verification.status === "matched")) throw unresolved();
|
||||
const latest = attachment.verifications[0];
|
||||
if (!latest || latest.source_sha256 !== attachment.file_sha256 || latest.source_version !== attachment.receipt_ocr_version) throw unresolved();
|
||||
const recognition = JSON.parse(decryptStringOrThrow(config, latest.normalized_encrypted));
|
||||
const pages = recognition.pages ?? [];
|
||||
if (!["recognized", "needs_review"].includes(recognition.status) || !(recognition.pagesExpected > 0) ||
|
||||
recognition.pagesProcessed !== recognition.pagesExpected || pages.length !== recognition.pagesExpected) throw unresolved();
|
||||
const { isClearBankFailure } = loanPayments();
|
||||
const candidates = batchCandidates(config, batch);
|
||||
const digits = (value) => String(value ?? "").replace(/\D/g, "");
|
||||
for (const page of pages) {
|
||||
if ((page.issues ?? []).some((issue) => UNSETTLED_STATUS_ISSUES.includes(issue))) throw unresolved();
|
||||
// A failed page counts only under the same strict rule as failure confirmation,
|
||||
// applied page by page: a failed page proves no payment on that page, so a
|
||||
// multi-page voided slip passes only if every page is a clear failure or a
|
||||
// readable, clearly different payment.
|
||||
if (page.transactionStatus === "failed") {
|
||||
if (isClearBankFailure({ status: "needs_review", pagesExpected: 1, pagesProcessed: 1, pages: [{ ...page, pageNumber: 1 }] })) continue;
|
||||
throw unresolved();
|
||||
}
|
||||
// Any other page must be fully readable (the recognizer reports no issue).
|
||||
if ((page.issues ?? []).length) throw unresolved();
|
||||
if (page.transactionStatus !== "success" || page.amount == null || !(Number(page.amount) > 0) || !page.payerAccount || !page.payeeAccount) throw unresolved();
|
||||
// "Clearly another payment": a definite mismatch AND at least one account
|
||||
// differs from every candidate. Same accounts with a different amount may
|
||||
// be a misread of this very payment, so it keeps blocking.
|
||||
if (matchBankSlip({ ...page, issues: [] }, candidates).status !== "mismatch") throw unresolved();
|
||||
const sameAccounts = candidates.some((candidate) => digits(candidate.payerAccount) === digits(page.payerAccount) &&
|
||||
digits(candidate.payeeAccount) === digits(page.payeeAccount));
|
||||
if (sameAccounts) throw unresolved();
|
||||
}
|
||||
if (!voidedAttachmentResolved(config, batch, attachment)) throw buildBusinessValidationError("loan.voided_receipt_unresolved", {}, 409);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,4 +244,5 @@ async function bankSubmissionState(tx, batch) {
|
||||
module.exports = {
|
||||
recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation,
|
||||
reviewSubmissionAttestation, assertSingleAttestedSubmission, assertVoidedReceiptsResolved, bankSubmissionState, submissionFacts,
|
||||
attestationMatches, voidedAttachmentResolved,
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户