借款对账 S1、S2:导出与银行提交分开登记、确认失败收紧及失败回单识别口径(09-17-company-loan-principal-repayment)

S1(迁移 20261007010000)
- 借款导出逐次记录;存量批次保留已知导出次数为历史汇总,不视为真实导出,存在即拦截
- 银行提交登记(只能撤回一次)与提交清单确认;独立复核,复核后任何登记、撤回、导出即失效
- 确认失败只计未作废附件;作废附件无法证明为明确失败或确属他笔付款即阻断
- S3 前临时拦截:多次导出、多次提交、清单未复核或历史汇总均不能确认失败
- 确认失败改按付款后规则;确认人不得兼任导出、提交登记、清单确认

S2(迁移 20261007020000)
- 存在同金额候选时须有已复核、覆盖提交清单的失败归属;结算与失败归属分槽

失败回单识别口径(用户同意并入)
- 新增原始状态措辞字段(多字段原文不一致即为空);失败事实严格判定:单页完整、
  仅“交易失败/转账失败/付款失败/支付失败”、唯一问题为交易失败、字段齐全;
  已撤销、已冲正、已退汇不接受;顶层识别状态与成功匹配不变
- 测试改走真实识别与归一化链,仅替换云端 OCR 响应

验证:借款 HTTP 41 组、浏览器 7 组、76 迁移;server 4463;共享回单测试 237;
三轮同模型只读复核的阻断项均已修复。

注意:界面未完成,不能单独部署;真实回单待验。未推送、未部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-07 11:47:24 +08:00
共同撰写人 Claude Opus 5.5
父节点 3fb33fe93c
当前提交 db28706177
共修改 16 个文件,包含 986 行新增和 56 行删除
+227
查看文件
@@ -0,0 +1,227 @@
const { createHash } = require("node:crypto");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow } = require("./crypto");
const { auditLoan } = require("./companyLoans");
// Loan bank-result reconciliation S1 (design.md 3.1, 3.3; implement.md S1).
// Payment-order exports and bank submissions are separate facts: one export
// file may be submitted zero, one or several times, and re-exports reuse the
// same customer serial. Failure/termination evidence must cover the reviewed
// submission list, never the export count or the attachment count.
const SUBMISSION_CHANNELS = ["online_banking_import", "manual_entry", "other"];
function loanPayments() {
// Lazy: companyLoanPayments requires this module for failure confirmation.
return require("./companyLoanPayments");
}
async function recordLoanPaymentOrderExport(tx, batch, request, generated) {
const previous = await tx.paymentOrderExport.count({ where: { payment_batch_id: batch.id } });
return tx.paymentOrderExport.create({ data: {
payment_batch_id: batch.id, sequence: previous + 1, exported_by: request.employee.id,
file_sha256: createHash("sha256").update(generated.buffer).digest("hex"),
customer_serials: generated.serials, total_amount: generated.totalAmount,
} });
}
function assertSubmissionBatch(batch) {
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status)) {
throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
}
}
async function registerBankSubmission(tx, batch, request, input) {
assertSubmissionBatch(batch);
if (!SUBMISSION_CHANNELS.includes(input.channel)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
if (input.exportId) {
const record = await tx.paymentOrderExport.findUnique({ where: { id: input.exportId } });
if (!record || record.payment_batch_id !== batch.id) throw buildBusinessValidationError("loan.submission_export_invalid", {}, 409);
} else if (input.channel === "online_banking_import") {
throw buildBusinessValidationError("loan.submission_export_invalid", {}, 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,
customer_serial: input.customerSerial ?? null, note: input.note, registered_by: request.employee.id,
} });
await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_REGISTERED", batch.loan_repayment_id, {
paymentBatchId: batch.id, submissionId: submission.id, channel: input.channel });
return submission;
}
async function withdrawBankSubmission(tx, batch, request, submissionId, reason) {
assertSubmissionBatch(batch);
const submission = await tx.loanBankSubmission.findUnique({ where: { id: submissionId } });
if (!submission || submission.payment_batch_id !== batch.id || submission.status !== "active") {
throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
}
const withdrawn = await tx.loanBankSubmission.update({ where: { id: submission.id }, data: {
status: "withdrawn", withdrawn_reason: reason, withdrawn_at: new Date() } });
await auditLoan(tx, request, "LOAN_BANK_SUBMISSION_WITHDRAWN", batch.loan_repayment_id, {
paymentBatchId: batch.id, submissionId: submission.id });
return withdrawn;
}
async function submissionFacts(tx, batch) {
const [exports, legacy, submissions] = await Promise.all([
tx.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "asc" } }),
tx.loanLegacyExportSummary.findUnique({ where: { payment_batch_id: batch.id } }),
tx.loanBankSubmission.findMany({ where: { payment_batch_id: batch.id }, orderBy: [{ submitted_at: "asc" }, { id: "asc" }] }),
]);
const active = submissions.filter((item) => item.status === "active");
return { exports, legacy, submissions, active,
activeIds: active.map((item) => item.id).sort(), allIds: submissions.map((item) => item.id).sort(),
exportIds: exports.map((item) => item.id).sort() };
}
const sameIds = (stored, current) => {
const left = Array.isArray(stored) ? [...stored].sort() : null;
return Boolean(left) && left.length === current.length && left.every((value, index) => value === current[index]);
};
// Bound to every submission row (withdrawn included) and every export: any
// registration, withdrawal or export after review invalidates the approval,
// so an unreviewed add-then-withdraw cannot revive it.
function attestationMatches(attestation, facts) {
return Boolean(attestation) && sameIds(attestation.submission_ids, facts.activeIds) &&
sameIds(attestation.all_submission_ids, facts.allIds) && sameIds(attestation.export_ids, facts.exportIds);
}
async function activeAttestation(tx, batch) {
return tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } } });
}
// Finance attests that the active submissions are the complete set for the batch.
// A stale open/approved attestation (lists changed) is superseded in the same
// transaction so the batch can always move forward.
async function proposeSubmissionAttestation(tx, batch, request, note) {
assertSubmissionBatch(batch);
const facts = await submissionFacts(tx, batch);
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_required", {}, 409);
const active = await activeAttestation(tx, batch);
if (active) {
if (attestationMatches(active, facts)) throw buildBusinessValidationError("loan.submission_attestation_exists", {}, 409);
await tx.loanBankSubmissionAttestation.update({ where: { id: active.id }, data: { status: "superseded", superseded_at: new Date() } });
await auditLoan(tx, request, "LOAN_SUBMISSION_ATTESTATION_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: active.id });
}
const attestation = await tx.loanBankSubmissionAttestation.create({ data: {
payment_batch_id: batch.id, submission_ids: facts.activeIds, all_submission_ids: facts.allIds, export_ids: facts.exportIds,
note, proposed_by: request.employee.id } });
await auditLoan(tx, request, "LOAN_SUBMISSION_ATTESTATION_PROPOSED", batch.loan_repayment_id, {
paymentBatchId: batch.id, attestationId: attestation.id, submissionCount: facts.activeIds.length });
return attestation;
}
// Independent of everyone who produced the export, the submissions or the
// attestation, plus the batch creator, submitter and lender employee.
async function reviewSubmissionAttestation(tx, batch, request, attestationId, input) {
assertSubmissionBatch(batch);
const attestation = await tx.loanBankSubmissionAttestation.findUnique({ where: { id: attestationId } });
if (!attestation || attestation.payment_batch_id !== batch.id || attestation.status !== "proposed") {
throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
}
const { readLoanRepayment, activeReviewer } = loanPayments();
const repayment = await readLoanRepayment(tx, batch);
const facts = await submissionFacts(tx, batch);
const excluded = [attestation.proposed_by, batch.created_by, repayment.submitted_by, repayment.lender.employee_id,
batch.payment_order_last_exported_by, facts.legacy?.last_exported_by,
...facts.exports.map((item) => item.exported_by), ...facts.submissions.map((item) => item.registered_by)];
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
if (input.decision === "approve" && !attestationMatches(attestation, facts)) throw buildBusinessValidationError("loan.version_changed", {}, 409);
const reviewed = await tx.loanBankSubmissionAttestation.update({ where: { id: attestation.id }, data: {
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_SUBMISSION_ATTESTATION_APPROVED" : "LOAN_SUBMISSION_ATTESTATION_REJECTED",
batch.loan_repayment_id, { paymentBatchId: batch.id, attestationId: attestation.id });
return reviewed;
}
// S1 temporary gate for failure confirmation (until S3 adds per-submission
// terminal evidence): exactly one reviewed submission, at most one export and no
// legacy summary. Returns the attested submission ids for the S2 attribution.
async function assertSingleAttestedSubmission(tx, batch) {
const facts = await submissionFacts(tx, batch);
if (facts.legacy) throw buildBusinessValidationError("loan.submission_history_unverified", {}, 409);
if (facts.exports.length > 1 || facts.active.length > 1) throw buildBusinessValidationError("loan.multiple_submissions_unresolved", {}, 409);
if (!facts.active.length) throw buildBusinessValidationError("loan.submission_list_unverified", {}, 409);
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);
}
return facts.activeIds;
}
// Voiding an attachment changes material use, not bank facts. Fail closed: a
// voided attachment is ignored only if its current-version recognition shows
// either an explicit failure, or a readable transaction that is clearly not
// this batch's payment (amount/accounts differ, no pending or ambiguous status).
// No verification (voided before OCR), unreadable or ambiguous status, a match,
// or a live manual-receipt proposal keeps blocking.
const UNSETTLED_STATUS_ISSUES = ["transaction_pending", "transactionStatus_ambiguous", "transactionStatus_unresolved"];
async function assertVoidedReceiptsResolved(tx, config, batch) {
const { batchCandidates } = require("./paymentReceiptVerification");
const { matchBankSlip } = require("./paymentReceiptMatching");
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();
}
}
}
async function bankSubmissionState(tx, batch) {
const facts = await submissionFacts(tx, batch);
const current = await tx.loanBankSubmissionAttestation.findFirst({ where: { payment_batch_id: batch.id },
orderBy: [{ proposed_at: "desc" }, { id: "desc" }] });
return {
exports: facts.exports.map((item) => ({ id: item.id, sequence: item.sequence, exportedAt: item.exported_at,
exportedBy: item.exported_by, customerSerials: item.customer_serials })),
legacy: facts.legacy ? { knownExportCount: facts.legacy.known_export_count, lastExportedAt: facts.legacy.last_exported_at,
unknownFacts: facts.legacy.unknown_facts.split(",") } : null,
submissions: facts.submissions.map((item) => ({ id: item.id, exportId: item.export_id, channel: item.channel,
submittedAt: item.submitted_at, bankInstructionRef: item.bank_instruction_ref, customerSerial: item.customer_serial,
note: item.note, status: item.status, registeredBy: item.registered_by })),
attestation: current ? { id: current.id, status: current.status, proposedBy: current.proposed_by, reviewedBy: current.reviewed_by,
stale: ["proposed", "approved"].includes(current.status) && !attestationMatches(current, facts) } : null,
};
}
module.exports = {
recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation,
reviewSubmissionAttestation, assertSingleAttestedSubmission, assertVoidedReceiptsResolved, bankSubmissionState, submissionFacts,
};