借款对账 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]>
这个提交包含在:
@@ -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,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户