文件
hyb-oa/server/src/services/loanBankSubmissions.js
T
wangsli ce11dea976 支持财务经办与公司代表关联方还款确认(09-17-company-loan-principal-repayment)
公司只有财务和出借收款经理两名办理人,新增限定公司、出借人和借款范围的关联方确认模式。
仅豁免指定公司代表的收款人关联;经办、自审、证据、交易归属、资金占用及 Q6 限制保留。
贯通付款、人工回单、银行对账、补证恢复、晚到补记与追回;电脑和手机显示实际确认身份。
授权版本、付款与对账停用事件及历史决定只增不改,新增迁移 20261009010000_loan_related_approval。

本地合成验证:82 迁移、HTTP 109 组、浏览器 33 组、数据库约束 9 组通过;13 个有效资金守卫变异均捕获。
已有服务端全量 4519 项通过、1 项原有跳过,后续定向 77 项通过;提示收尾另有 908 项合同测试及前端构建通过。
按用户授权提交后对干净候选运行正式 verify-candidate,不沿用旧候选回执。

限制:独立代码复核待完成(inline 模式);真实回单待验。本次仅本地提交,不推送、不部署。
生成关联决定后不能直接降回不理解该模式的旧代码;真实授权、录入、生产核实和付款仍须明确批准。
2026-10-08 15:35:33 +08:00

256 行
16 KiB
JavaScript

const { createHash } = require("node:crypto");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { decryptStringOrThrow } = require("./crypto");
const { auditLoan } = require("./companyLoans");
const approvalPolicy = require("./loanApprovalPolicy");
// 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) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
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) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
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);
}
// 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: 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;
}
async function withdrawBankSubmission(tx, batch, request, submissionId, reason) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
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) {
await approvalPolicy.assertMaker(tx, batch, request.employee.id);
assertSubmissionBatch(batch);
const facts = await submissionFacts(tx, batch);
if (!facts.active.length && (!facts.exports.length || facts.submissions.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, ...approvalPolicy.payeeConflicts(repayment),
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, batch });
if (input.decision === "approve" && !attestationMatches(attestation, facts)) throw buildBusinessValidationError("loan.version_changed", {}, 409);
const reviewed = await tx.loanBankSubmissionAttestation.update({ where: { id: attestation.id }, data: {
...await approvalPolicy.decisionFields(tx, batch, request.employee.id, { targetId: attestation.id, conflictIds: excluded }),
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, ...approvalPolicy.auditContext(reviewed) });
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);
await approvalPolicy.assertDecision(tx, batch, approved);
const [submission] = facts.active;
// 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;
}
// 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"];
// 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 } } } });
for (const attachment of voided) {
if (!voidedAttachmentResolved(config, batch, attachment)) throw buildBusinessValidationError("loan.voided_receipt_unresolved", {}, 409);
}
}
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,
attestationMatches, voidedAttachmentResolved,
};