借款还款同金额回单须经独立复核确认归属后方可核销(09-17-company-loan-principal-repayment)

- 同一出借人同金额的其他批次曾导出或上传回单(含确认失败后已取消的)时,
  最终核销入口要求已复核的归属确认;OCR 匹配与人工回单复核均不能绕过,
  证据不足保持待对账,本金与占用不变;存在候选时失败回单不能释放占用
- 新表 loan_receipt_attributions(迁移 20261006010000):绑定交易指纹、
  回单核验与人工录入证据、当时候选集合;依据类型不含时间吻合;复核人须
  当前有资格且独立于登记人、批次创建人、提交人、导出人、回单上传人、
  人工录入人与出借员工;失效记录在重新登记时标为 superseded,历史只增不改
- 借款回单上传取出借人锁,与核销候选检查串行;串行只保证按当时已知候选
  判断,核销之后才出现的新证据留待晚到结果对账流程处理
- 付款就绪检查反映归属要求;页面登记/复核/失效重登,写明同额分期每期需确认
- 测试:两个错误核销复现改为回归断言;证据绑定、确定性并发两种顺序、
  PC 浏览器完整登记复核重登流程;变异测试均变红

注意:借款功能尚不具备真实还款验收条件(失败回单与对账流程、离职后确认
失败、真实回单样本、手机端弹窗、独立复核待完成);未推送、未部署。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
这个提交包含在:
2026-10-06 20:20:51 +08:00
共同撰写人 Claude Opus 5.5
父节点 b639422211
当前提交 b88790d396
共修改 13 个文件,包含 718 行新增和 31 行删除
@@ -0,0 +1,83 @@
-- Receipt-to-batch attribution for loan repayments. When another same-lender,
-- same-amount loan batch was ever exported or had receipts, a matched receipt
-- alone does not prove which batch it paid; settlement then requires an
-- independently reviewed attribution bound to the exact transaction fingerprint
-- and to the receipt evidence (verification and manual-proposal ids) reviewed.
-- CreateTable
CREATE TABLE "loan_receipt_attributions" (
"id" UUID NOT NULL,
"payment_batch_id" UUID NOT NULL,
"fingerprint" VARCHAR(64) NOT NULL,
"key_id" VARCHAR(64) NOT NULL,
"verification_ids" JSONB NOT NULL,
"manual_proposal_ids" JSONB NOT NULL,
"competitor_batch_ids" JSONB NOT NULL,
"basis_kind" VARCHAR(32) NOT NULL,
"basis_note" VARCHAR(1000) NOT NULL,
"exclusion_note" VARCHAR(1000) NOT NULL,
"status" VARCHAR(16) NOT NULL DEFAULT 'proposed',
"proposed_by" UUID NOT NULL,
"proposed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewed_by" UUID,
"reviewed_at" TIMESTAMP(3),
"review_note" VARCHAR(500),
"superseded_at" TIMESTAMP(3),
CONSTRAINT "loan_receipt_attributions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "loan_receipt_attributions_status_check" CHECK ("status" IN ('proposed', 'approved', 'rejected', 'superseded')),
CONSTRAINT "loan_receipt_attributions_basis_kind_check" CHECK ("basis_kind" IN
('customer_serial', 'bank_memo', 'bank_written_confirmation', 'competitors_resolved')),
CONSTRAINT "loan_receipt_attributions_notes_check" CHECK (
char_length(btrim("basis_note")) >= 10 AND char_length(btrim("exclusion_note")) >= 10),
CONSTRAINT "loan_receipt_attributions_evidence_check" CHECK (
jsonb_typeof("verification_ids") = 'array' AND jsonb_array_length("verification_ids") > 0
AND jsonb_typeof("manual_proposal_ids") = 'array'),
CONSTRAINT "loan_receipt_attributions_competitors_check" CHECK (
jsonb_typeof("competitor_batch_ids") = 'array' AND jsonb_array_length("competitor_batch_ids") > 0),
CONSTRAINT "loan_receipt_attributions_review_check" CHECK (
("status" = 'proposed' AND "reviewed_by" IS NULL AND "reviewed_at" IS NULL)
OR ("status" IN ('approved', 'rejected') AND "reviewed_by" IS NOT NULL AND "reviewed_at" IS NOT NULL)
OR "status" = 'superseded'),
CONSTRAINT "loan_receipt_attributions_superseded_check" CHECK (("status" = 'superseded') = ("superseded_at" IS NOT NULL)),
CONSTRAINT "loan_receipt_attributions_independent_check" CHECK (
"reviewed_by" IS NULL OR "reviewed_by" <> "proposed_by")
);
-- CreateIndex
CREATE INDEX "loan_receipt_attributions_batch_fingerprint_idx" ON "loan_receipt_attributions"("payment_batch_id", "fingerprint");
-- At most one open or approved attribution per batch; rejected and superseded rows stay as history.
CREATE UNIQUE INDEX "loan_receipt_attributions_one_active_per_batch"
ON "loan_receipt_attributions"("payment_batch_id") WHERE "status" IN ('proposed', 'approved');
-- AddForeignKey
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_payment_batch_id_fkey" FOREIGN KEY ("payment_batch_id") REFERENCES "payment_batches"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_proposed_by_fkey" FOREIGN KEY ("proposed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "loan_receipt_attributions" ADD CONSTRAINT "loan_receipt_attributions_reviewed_by_fkey" FOREIGN KEY ("reviewed_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- History is append-only: proposed -> approved/rejected/superseded, approved ->
-- superseded (when the receipt or competitor set changed); nothing else, no deletes.
CREATE FUNCTION guard_loan_receipt_attribution_history() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'DELETE' THEN RAISE EXCEPTION 'loan receipt attribution history is immutable'; END IF;
IF OLD."status" IN ('rejected', 'superseded') THEN RAISE EXCEPTION 'closed loan receipt attribution is immutable'; END IF;
IF OLD."status" = 'approved' AND (NEW."status" <> 'superseded' OR NEW."reviewed_by" IS DISTINCT FROM OLD."reviewed_by"
OR NEW."reviewed_at" IS DISTINCT FROM OLD."reviewed_at" OR NEW."review_note" IS DISTINCT FROM OLD."review_note") THEN
RAISE EXCEPTION 'approved loan receipt attribution can only be superseded';
END IF;
IF OLD."status" = 'proposed' AND NEW."status" IN ('proposed', 'superseded')
AND (NEW."reviewed_by" IS NOT NULL OR NEW."reviewed_at" IS NOT NULL OR NEW."review_note" IS NOT NULL) THEN
RAISE EXCEPTION 'unreviewed loan receipt attribution cannot carry review fields';
END IF;
IF NEW."payment_batch_id" <> OLD."payment_batch_id" OR NEW."fingerprint" <> OLD."fingerprint" OR NEW."key_id" <> OLD."key_id"
OR NEW."verification_ids" <> OLD."verification_ids" OR NEW."manual_proposal_ids" <> OLD."manual_proposal_ids"
OR NEW."competitor_batch_ids" <> OLD."competitor_batch_ids" OR NEW."basis_kind" <> OLD."basis_kind"
OR NEW."basis_note" <> OLD."basis_note" OR NEW."exclusion_note" <> OLD."exclusion_note"
OR NEW."proposed_by" <> OLD."proposed_by" OR NEW."proposed_at" <> OLD."proposed_at" THEN
RAISE EXCEPTION 'loan receipt attribution proposal is immutable';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_receipt_attribution_history BEFORE UPDATE OR DELETE ON "loan_receipt_attributions"
FOR EACH ROW EXECUTE FUNCTION guard_loan_receipt_attribution_history();
+34
查看文件
@@ -735,6 +735,8 @@ model Employee {
loan_lender_identities LoanLender[]
loan_evidence_uploads LoanEvidence[]
loan_receipt_attributions_proposed LoanReceiptAttribution[] @relation("LoanReceiptAttributionProposer")
loan_receipt_attributions_reviewed LoanReceiptAttribution[] @relation("LoanReceiptAttributionReviewer")
@@map("employees")
}
@@ -1974,6 +1976,7 @@ model PaymentBatch {
@@index([legal_entity_id], map: "idx_payment_batches_legal_entity")
@@index([payment_account_id], map: "idx_payment_batches_payment_account")
@@index([payment_order_last_exported_by], map: "idx_payment_batches_order_exported_by")
loan_receipt_attributions LoanReceiptAttribution[]
@@map("payment_batches")
}
@@ -3979,3 +3982,34 @@ model LoanEvidence {
@@unique([loan_id, file_sha256, kind])
@@map("loan_evidences")
}
// Independently reviewed receipt-to-batch attribution for ambiguous loan
// repayments (same lender and amount as another exported/receipted batch).
// Partial unique index (one proposed/approved per batch) and history trigger
// live in migration 20261006010000_loan_receipt_attribution.
model LoanReceiptAttribution {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
fingerprint String @db.VarChar(64)
key_id String @db.VarChar(64)
verification_ids Json
manual_proposal_ids Json
competitor_batch_ids Json
basis_kind String @db.VarChar(32)
basis_note String @db.VarChar(1000)
exclusion_note String @db.VarChar(1000)
status String @default("proposed") @db.VarChar(16)
proposed_by String @db.Uuid
proposed_at DateTime @default(now())
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String? @db.VarChar(500)
superseded_at DateTime?
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict)
proposer Employee @relation("LoanReceiptAttributionProposer", fields: [proposed_by], references: [id], onDelete: Restrict)
reviewer Employee? @relation("LoanReceiptAttributionReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict)
@@index([payment_batch_id, fingerprint], map: "loan_receipt_attributions_batch_fingerprint_idx")
@@map("loan_receipt_attributions")
}
+4
查看文件
@@ -18,6 +18,10 @@ const ERROR_REASONS = [
{"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":"loan.receipt_attribution_required","domain":"loan","audience":"user","defaultMessage":"该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_not_required","domain":"loan","audience":"user","defaultMessage":"本还款没有同金额的其他候选批次,无需确认回单归属。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_unavailable","domain":"loan","audience":"user","defaultMessage":"尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。","recovery":"follow_guidance"},
{"code":"loan.receipt_attribution_exists","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"},
+40 -4
查看文件
@@ -1,8 +1,9 @@
const { resolveNumberingContext, issueBusinessNumber } = require("../services/businessNumbering");
const { ensureLoanManager, auditLoan, createIdempotently } = require("../services/companyLoans");
const { ensureLoanManager, auditLoan, createIdempotently, lockLender } = require("../services/companyLoans");
const { loadPendingPaymentResponsibility } = require("../services/paymentResponsibility");
const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure } = require("../services/companyLoanPayments");
activeReviewer, settleLoanRepayment, cancelLoanRepayment, mapRepayment, confirmLoanBankFailure,
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { ERROR_REASONS } = require("../constants/errorReasons");
const { queueReceiptRetry, ReceiptVerificationError, canRetryReceiptRecognition, claimVerifiedReceipts } = require("../services/paymentReceiptVerification");
@@ -1045,7 +1046,10 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
: [];
const actorNames = new Map(actors.map((actor) => [actor.id, actor.name]));
return {
...(batch.entity_type === "loan_repayment" ? { loanRepayment: mapRepayment(await readLoanRepayment(tx, batch), batch) } : {}),
...(batch.entity_type === "loan_repayment" ? { loanRepayment: await (async () => {
const repayment = await readLoanRepayment(tx, batch);
return { ...mapRepayment(repayment, batch), receiptAttribution: await loanReceiptAttributionState(tx, batch, repayment, config) };
})() } : {}),
...mapBatch(batch, openRiskFlags, legacyPayees, {
includePayee: true,
includeExpenseItems,
@@ -1489,6 +1493,32 @@ async function paymentBatchesRoutes(fastify) {
requestKey: UUID_SCHEMA, lenderId: UUID_SCHEMA, reason: z.string().trim().min(2).max(500),
allocations: z.array(z.object({ loanId: UUID_SCHEMA, amount: z.string().regex(/^\d{1,12}(\.\d{1,2})?$/) }).strict()).min(1).max(100),
}).strict();
const ATTRIBUTION_PROPOSAL_SCHEMA = z.object({
basisKind: z.enum(["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"]),
basisNote: z.string().trim().min(10).max(1000), exclusionNote: z.string().trim().min(10).max(1000),
}).strict();
const ATTRIBUTION_REVIEW_SCHEMA = z.object({ decision: z.enum(["approve", "reject"]), reason: z.string().trim().min(2).max(500) }).strict();
async function attributionTransaction(request, work) {
const id = parseId(request.params.id);
return fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, id);
if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
await ensureFeatureEnabled(fastify);
try { await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
return await work(tx, batch); } catch (error) { mapManualReceiptError(error); }
});
}
fastify.post("/payment-batches/:id/loan-receipt-attribution", { preHandler: authenticateBatch }, async (request, reply) => {
const body = parseBody(ATTRIBUTION_PROPOSAL_SCHEMA, request.body);
const result = await attributionTransaction(request, (tx, batch) => proposeReceiptAttribution(tx, fastify.config, batch, request, body));
return sendData(reply, request, { id: result.id, status: result.status });
});
fastify.post("/payment-batches/:id/loan-receipt-attribution/:attributionId/review", { preHandler: authenticateBatch }, async (request, reply) => {
const attributionId = parseId(request.params.attributionId);
const body = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
const result = await attributionTransaction(request, (tx, batch) => reviewReceiptAttribution(tx, fastify.config, batch, request, attributionId, body));
return sendData(reply, request, { id: result.id, status: result.status });
});
fastify.post("/payment-batches/:id/bank-failure-review", { preHandler: authenticateBatch }, async (request, reply) => {
const id = parseId(request.params.id);
const { attachmentId } = parseBody(z.object({ attachmentId: UUID_SCHEMA }).strict(), request.body);
@@ -1938,6 +1968,9 @@ async function paymentBatchesRoutes(fastify) {
if (!["reviewed", "failed"].includes(batch.status)) return { ready: false };
try {
if (["reimbursement", "loan_repayment"].includes(batch.entity_type)) await claimVerifiedReceipts(tx, fastify.config, batch, { readOnly: true });
if (batch.entity_type === "loan_repayment" && !await loanAttributionSatisfied(tx, fastify.config, batch)) {
return { ready: false, receiptAttributionRequired: true };
}
return { ready: true };
} catch (error) {
if (!(error instanceof ReceiptVerificationError)) throw error;
@@ -2028,6 +2061,9 @@ async function paymentBatchesRoutes(fastify) {
if (lockedBatch.entity_type === "loan_repayment") {
const repayment = await readLoanRepayment(tx, lockedBatch);
// Becoming a same-amount competitor must serialize with settlement's
// competitor check, which runs under the same lender lock (batch -> lender).
await lockLender(tx, repayment.lender_id);
if (repayment.bank_result_state === "confirmed_failed") throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409);
await tx.loanRepayment.update({ where: { id: repayment.id }, data: { bank_result_state: "unknown" } });
}
@@ -2656,7 +2692,7 @@ async function paymentBatchesRoutes(fastify) {
successfulReimbursementIds.push(item.entity_id);
}
} else if (batch.entity_type === "loan_repayment") {
await settleLoanRepayment(tx, batch, request);
await settleLoanRepayment(tx, batch, request, fastify.config);
} else if (batch.entity_type === "travel_order") {
await executeTravelOrderPayment(tx, {
batch,
+181 -2
查看文件
@@ -169,10 +169,63 @@ function repaymentMatchesInput(repayment, input) {
repayment.allocations.some((saved) => saved.loan_id === a.loanId && new Prisma.Decimal(saved.amount).eq(a.amount)));
}
async function settleLoanRepayment(tx, batch, request) {
const ATTRIBUTION_BASIS_KINDS = ["customer_serial", "bank_memo", "bank_written_confirmation", "competitors_resolved"];
// A matched receipt proves amount/accounts/currency/status, not which loan batch
// the bank paid. Any other batch for the same lender and amount that ever reached
// the bank (exported or receipted), including ones cancelled after a confirmed
// failure, could own the same transaction; settlement then needs attribution.
async function competingLoanBatches(tx, batch, repayment) {
return tx.paymentBatch.findMany({ where: {
id: { not: batch.id }, entity_type: "loan_repayment",
loan_repayment: { lender_id: repayment.lender_id, amount: repayment.amount },
OR: [{ payment_order_export_count: { gt: 0 } }, { receipt_attachments: { some: {} } }],
}, select: { id: true, batch_no: true, status: true }, orderBy: { id: "asc" } });
}
const sameIdSet = (stored, current) => {
const left = Array.isArray(stored) ? [...stored].sort() : null;
const right = [...(current ?? [])].sort();
return Boolean(left) && left.length === right.length && left.every((value, index) => value === right[index]);
};
// Valid only for the exact transaction, the exact receipt evidence that was
// reviewed (same verifications and manual proposals; a re-scan or re-keyed
// receipt of the same transaction invalidates it) and every current competitor.
function attributionMatches(attribution, claim, competitors) {
return Boolean(claim) && attribution.fingerprint === claim.fingerprint && attribution.key_id === claim.keyId &&
sameIdSet(attribution.verification_ids, claim.verificationIds) && sameIdSet(attribution.manual_proposal_ids, claim.manualProposalIds) &&
attributionCovers(attribution, competitors);
}
function attributionCovers(attribution, competitors) {
const covered = Array.isArray(attribution?.competitor_batch_ids) ? attribution.competitor_batch_ids : [];
return competitors.every((item) => covered.includes(item.id));
}
// Final settlement gate: runs after claimVerifiedReceipts created this item's
// claim in the same transaction, so OCR matches and manual receipt reviews both
// pass through it. Only an approved attribution for the exact transaction
// fingerprint, covering every current competitor, unlocks settlement.
async function assertReceiptAttributed(tx, config, batch, repayment) {
const competitors = await competingLoanBatches(tx, batch, repayment);
if (!competitors.length) return;
const claim = await tx.paymentReceiptClaim.findUnique({ where: { item_id: batch.items[0].id } });
// Re-derive the evidence behind this claim (read-only; the claim row itself
// stores only one verification) so the approval's evidence binding is checked.
const planned = claim && await plannedClaimOrNull(tx, config, batch);
const approved = planned && planned.fingerprint === claim.fingerprint && await tx.loanReceiptAttribution.findFirst({ where: {
payment_batch_id: batch.id, status: "approved", fingerprint: claim.fingerprint, key_id: claim.key_id } });
if (!approved || !attributionMatches(approved, planned, competitors)) {
throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409);
}
}
async function settleLoanRepayment(tx, batch, request, config) {
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 assertReceiptAttributed(tx, config, batch, repayment);
await tx.loanRepaymentAllocation.updateMany({ where: { repayment_id: repayment.id, reservation_state: "reserved" },
data: { reservation_state: "settled", settled_at: new Date() } });
await tx.loanRepayment.update({ where: { id: repayment.id }, data: {
@@ -210,6 +263,10 @@ async function confirmLoanBankFailure(tx, config, batch, request, attachmentId)
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
await tx.$queryRaw`SELECT id FROM payment_batch_receipt_attachments WHERE id=${attachmentId}::uuid FOR UPDATE`;
const repayment = await validateLoanPayment(tx, batch, { lock: true, reviewerId: request.employee.id });
// A failed receipt that may belong to a same-lender, same-amount batch cannot
// release this reservation; without an attribution path for failures the
// repayment stays reserved for bank reconciliation.
if ((await competingLoanBatches(tx, batch, repayment)).length) { throw buildBusinessValidationError("loan.receipt_attribution_required", {}, 409); };
const attachment = await tx.paymentBatchReceiptAttachment.findUnique({ where: { id: attachmentId } });
// More than one bank attempt/evidence is ambiguous: one failed attempt cannot
// establish that another attempt did not pay. Preserve all reservations.
@@ -237,6 +294,127 @@ async function confirmLoanBankFailure(tx, config, batch, request, attachmentId)
await auditLoan(tx, request, "LOAN_BANK_FAILURE_CONFIRMED", repayment.id, { attachmentId, verificationId: verification.id });
}
async function plannedLoanClaim(tx, config, batch) {
const { claimVerifiedReceipts, ReceiptVerificationError } = require("./paymentReceiptVerification");
let planned;
try { planned = await claimVerifiedReceipts(tx, config, batch, { readOnly: true }); }
catch (error) {
if (error instanceof ReceiptVerificationError && error.code === "receipt_verification_required") { throw buildBusinessValidationError("loan.receipt_attribution_unavailable", {}, 409); };
throw error;
}
const claim = planned?.find((entry) => entry.itemId === batch.items[0].id);
if (!claim) { throw buildBusinessValidationError("loan.receipt_attribution_unavailable", {}, 409); };
return claim;
}
async function assertAttributableBatch(tx, batch, { requireCompetitors = true } = {}) {
if (batch.entity_type !== "loan_repayment" || !["reviewed", "failed"].includes(batch.status)) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
const repayment = await validateLoanPayment(tx, batch, { lock: true, postPayment: true });
if (repayment.bank_result_state === "confirmed_failed" || repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
const competitors = await competingLoanBatches(tx, batch, repayment);
if (requireCompetitors && !competitors.length) { throw buildBusinessValidationError("loan.receipt_attribution_not_required", {}, 409); };
return { repayment, competitors };
}
// Finance documents why the verified bank transaction belongs to this batch and
// why each competitor is excluded. Time alignment alone is not an accepted basis.
async function proposeReceiptAttribution(tx, config, batch, request, input) {
if (!ATTRIBUTION_BASIS_KINDS.includes(input.basisKind)) { throw buildBusinessValidationError("loan.invalid_input", {}, 400); };
const { competitors } = await assertAttributableBatch(tx, batch);
const claim = await plannedLoanClaim(tx, config, batch);
// A still-valid open/approved record blocks duplicates. One whose receipt
// transaction or competitor coverage no longer matches is closed as
// superseded in the same transaction so the batch never deadlocks.
const active = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, status: { in: ["proposed", "approved"] } } });
if (active) {
if (attributionMatches(active, claim, competitors)) { throw buildBusinessValidationError("loan.receipt_attribution_exists", {}, 409); };
await tx.loanReceiptAttribution.update({ where: { id: active.id }, data: { status: "superseded", superseded_at: new Date() } });
await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_SUPERSEDED", batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: active.id,
reason: active.fingerprint !== claim.fingerprint || active.key_id !== claim.keyId ? "receipt_transaction_changed"
: attributionCovers(active, competitors) ? "receipt_evidence_changed" : "competitors_changed" });
}
const attribution = await tx.loanReceiptAttribution.create({ data: {
payment_batch_id: batch.id, fingerprint: claim.fingerprint, key_id: claim.keyId,
verification_ids: claim.verificationIds, manual_proposal_ids: claim.manualProposalIds,
competitor_batch_ids: competitors.map((item) => item.id), basis_kind: input.basisKind,
basis_note: input.basisNote, exclusion_note: input.exclusionNote, proposed_by: request.employee.id,
} });
await auditLoan(tx, request, "LOAN_RECEIPT_ATTRIBUTION_PROPOSED", batch.loan_repayment_id, {
paymentBatchId: batch.id, attributionId: attribution.id, basisKind: input.basisKind, competitorCount: competitors.length });
return attribution;
}
// Independent review: the current reviewer must be eligible now and differ from
// the proposer, batch creator, submitter and lender employee. Approval is
// refused if the receipt evidence or competitor set changed since proposal.
async function reviewReceiptAttribution(tx, config, batch, request, attributionId, input) {
const attribution = await tx.loanReceiptAttribution.findUnique({ where: { id: attributionId } });
if (!attribution || attribution.payment_batch_id !== batch.id || attribution.status !== "proposed") { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
const { repayment } = await assertAttributableBatch(tx, batch, { requireCompetitors: false });
const excluded = [batch.created_by, repayment.submitted_by, repayment.lender.employee_id, attribution.proposed_by, batch.payment_order_last_exported_by];
if (input.decision === "approve") {
const { competitors } = await assertAttributableBatch(tx, batch);
const claim = await plannedLoanClaim(tx, config, batch);
if (!attributionMatches(attribution, claim, competitors)) { throw buildBusinessValidationError("loan.version_changed", {}, 409); };
// Also independent of everyone who produced the claimed evidence: uploaders
// of every receipt carrying this transaction and any manual-receipt proposer.
const verifications = await tx.paymentReceiptVerification.findMany({ where: { id: { in: claim.verificationIds } }, include: { attachment: true } });
const manual = await tx.paymentReceiptManualProposal.findMany({ where: { id: { in: claim.manualProposalIds } }, select: { proposed_by: true } });
excluded.push(...verifications.map((v) => v.attachment?.uploaded_by), ...manual.map((m) => m.proposed_by));
}
// Rejection needs no receipt, so an open proposal can always be closed.
await activeReviewer(tx, request.employee.id, excluded.filter(Boolean), { lock: true });
const reviewed = await tx.loanReceiptAttribution.update({ where: { id: attribution.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_RECEIPT_ATTRIBUTION_APPROVED" : "LOAN_RECEIPT_ATTRIBUTION_REJECTED",
batch.loan_repayment_id, { paymentBatchId: batch.id, attributionId: attribution.id });
return reviewed;
}
async function plannedClaimOrNull(tx, config, batch) {
const { claimVerifiedReceipts, ReceiptVerificationError } = require("./paymentReceiptVerification");
try { return (await claimVerifiedReceipts(tx, config, batch, { readOnly: true }))?.find((entry) => entry.itemId === batch.items[0].id) ?? null; }
catch (error) { if (error instanceof ReceiptVerificationError) return null; throw error; }
}
// Display only: batch detail is also the response of mutating routes, so any
// failure to rebuild the planned claim (e.g. key mismatch) degrades to unknown.
async function displayPlannedClaim(tx, config, batch) {
try { return { claim: await plannedClaimOrNull(tx, config, batch), known: true }; }
catch { return { claim: null, known: false }; }
}
// Read-only readiness for receipt-readiness: false when competitors exist and no
// approved attribution matches the receipt that execute would claim.
async function loanAttributionSatisfied(tx, config, batch) {
const repayment = await readLoanRepayment(tx, batch);
const competitors = await competingLoanBatches(tx, batch, repayment);
if (!competitors.length) return true;
const claim = await plannedClaimOrNull(tx, config, batch);
const approved = claim && await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id, status: "approved" } });
return Boolean(approved && attributionMatches(approved, claim, competitors));
}
async function loanReceiptAttributionState(tx, batch, repayment, config) {
const competitors = await competingLoanBatches(tx, batch, repayment);
const planned = competitors.length && config ? await displayPlannedClaim(tx, config, batch) : { claim: null, known: false };
const current = await tx.loanReceiptAttribution.findFirst({ where: { payment_batch_id: batch.id },
orderBy: [{ proposed_at: "desc" }, { id: "desc" }], include: { proposer: { select: { name: true } }, reviewer: { select: { name: true } } } });
return {
required: competitors.length > 0,
competitors: competitors.map((item) => ({ id: item.id, batchNo: item.batch_no, status: item.status })),
current: current ? { id: current.id, status: current.status, basisKind: current.basis_kind, basisNote: current.basis_note,
exclusionNote: current.exclusion_note, proposedBy: current.proposed_by, proposedByName: current.proposer?.name ?? null,
proposedAt: current.proposed_at, reviewedByName: current.reviewer?.name ?? null, reviewedAt: current.reviewed_at,
reviewNote: current.review_note, coversCurrentCompetitors: attributionCovers(current, competitors),
stale: ["proposed", "approved"].includes(current.status) && competitors.length > 0 && planned.known && planned.claim !== null &&
!attributionMatches(current, planned.claim, competitors) } : null,
receiptAvailable: planned.known ? planned.claim !== null : null,
};
}
function mapRepayment(repayment, batch) {
return {
id: repayment.id, repaymentNo: repayment.repayment_no, lenderId: repayment.lender_id,
@@ -251,4 +429,5 @@ function mapRepayment(repayment, batch) {
}
module.exports = { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRepayment, repaymentMatchesInput,
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure };
eligibleLoanReviewers, activeReviewer, settleLoanRepayment, cancelLoanRepayment, loanReceiptParticipants, mapRepayment, confirmLoanBankFailure,
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied };
@@ -149,7 +149,7 @@ async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {
});
requireFact(existingAliases.every((alias) => alias.claim_fingerprint === match.fingerprint && alias.key_id === keyId),
"receipt_transaction_reused");
claims.push({ match, exists: existing.length > 0, existingAliases });
claims.push({ match, options, exists: existing.length > 0, existingAliases });
}
requireFact(new Set(claims.map(({ match }) => match.fingerprint)).size === claims.length,
"receipt_transaction_reused");
@@ -160,7 +160,9 @@ async function claimVerifiedReceipts(tx, config, batch, { readOnly = false } = {
aliasOwners.set(alias, match.item_id);
}
}
if (readOnly) return;
if (readOnly) return claims.map(({ match, options }) => ({ itemId: match.item_id, fingerprint: match.fingerprint, keyId: match.key_id,
verificationIds: [...new Set(options.map((option) => option.verification_id))],
manualProposalIds: [...new Set(options.map((option) => option.manual_proposal_id).filter(Boolean))] }));
for (const { match, exists, existingAliases } of claims) {
if (!exists) {
await tx.paymentReceiptClaim.create({ data: {
@@ -16,6 +16,10 @@
"loan.independent_review_required": "本次操作须由具备业务权限的独立人员完成,制单人、提交人或收款人不能自审。",
"loan.bank_state_unsafe": "银行付款状态尚未明确,请先查证;当前不能释放额度或再次付款。",
"loan.handoff_not_ready": "本金付款尚未成功登记,或已完成会计交接,请核对付款状态。",
"loan.receipt_attribution_required": "该回单可能属于同一出借人的其他同金额还款,须登记归属依据并经独立复核,确认前不能核销或释放额度。",
"loan.receipt_attribution_not_required": "本还款没有同金额的其他候选批次,无需确认回单归属。",
"loan.receipt_attribution_unavailable": "尚无可用于确认归属的已核验银行回单,请先完成回单识别或人工核对。",
"loan.receipt_attribution_exists": "本还款已有待复核或已通过的回单归属确认,请勿重复提交。",
"loan.lender_in_flight": "该出借人有还款正在办理,处理完成或安全取消后才能修改身份关联和收款资料。",
"loan.payment_not_ready": "还款申请或付款状态已变化,请刷新核对后继续。",
"config.enabled_query_invalid": "启用状态筛选无效,请重新选择后查询。",