借款对账 SR-A/B:补证恢复与复核后释放(09-17-company-loan-principal-repayment)

保留原银行终态及依据,以恢复轮次绑定材料、候选和独立性;最终取消重验并原子消费批准、释放占用和记录审计。新增增量迁移、HTTP 回归及数据库保护。

验证:78 个迁移、HTTP 72 组、既有浏览器 7 组、数据库约束 9 组通过;7 个变异均被抓住。服务端首跑 4470 通过、6 失败、1 跳过,补齐 BIFF8 测试依赖后相关 146 项通过,未重跑完整套件。

限制:inline 主会话自查不等于独立复核,独立复核待完成;界面未完成,不能单独部署;真实回单待验。新恢复界面留待 S8,S4 另批,整体借款功能尚未完成验收。本次仅本地提交,不推送、不部署、不查询生产。
这个提交包含在:
2026-10-07 20:07:57 +08:00
父节点 14aee82d5c
当前提交 2e00d30fd0
共修改 15 个文件,包含 960 行新增和 9 行删除
@@ -0,0 +1,142 @@
-- SR: append recovery decisions without reopening the original bank outcome.
CREATE TABLE loan_release_recoveries (
id UUID PRIMARY KEY, payment_batch_id UUID NOT NULL REFERENCES payment_batches(id) ON DELETE RESTRICT,
sequence INTEGER NOT NULL CHECK(sequence > 0), previous_id UUID REFERENCES loan_release_recoveries(id) ON DELETE RESTRICT,
request_key UUID NOT NULL, original_state VARCHAR(32) NOT NULL CHECK(original_state IN ('confirmed_failed','confirmed_not_paid')),
original_ref TEXT NOT NULL CHECK(length(trim(original_ref)) > 0), reason VARCHAR(1000) NOT NULL CHECK(length(trim(reason))>=5),
started_by UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, started_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
status VARCHAR(16) NOT NULL DEFAULT 'collecting' CHECK(status IN ('collecting','proposed','approved','rejected','withdrawn','superseded','consumed')),
submitted_by UUID REFERENCES employees(id) ON DELETE RESTRICT, submitted_at TIMESTAMP(3), snapshot_json JSONB,
reviewed_by UUID REFERENCES employees(id) ON DELETE RESTRICT, reviewed_at TIMESTAMP(3), review_note VARCHAR(500), ended_at TIMESTAMP(3),
cancel_request_key UUID, consumed_by UUID REFERENCES employees(id) ON DELETE RESTRICT,
UNIQUE(payment_batch_id,sequence), UNIQUE(payment_batch_id,request_key),
CHECK(submitted_by IS NULL OR submitted_by=started_by),
CHECK(reviewed_by IS NULL OR (reviewed_by<>started_by AND reviewed_by<>submitted_by)),
CHECK(status NOT IN ('proposed','approved','rejected','consumed') OR (snapshot_json IS NOT NULL AND submitted_by IS NOT NULL AND submitted_at IS NOT NULL)),
CHECK(status NOT IN ('approved','rejected','consumed') OR (reviewed_by IS NOT NULL AND reviewed_at IS NOT NULL AND length(trim(review_note))>0)),
CHECK(status<>'consumed' OR (cancel_request_key IS NOT NULL AND consumed_by IS NOT NULL)),
CHECK(status NOT IN ('withdrawn','superseded','rejected','consumed') OR ended_at IS NOT NULL)
);
CREATE UNIQUE INDEX loan_release_recoveries_open ON loan_release_recoveries(payment_batch_id) WHERE status IN ('collecting','proposed','approved');
CREATE TABLE loan_release_recovery_events (
id UUID PRIMARY KEY, recovery_id UUID NOT NULL REFERENCES loan_release_recoveries(id) ON DELETE RESTRICT,
action VARCHAR(32) NOT NULL, actor_id UUID NOT NULL REFERENCES employees(id) ON DELETE RESTRICT, note VARCHAR(1000) NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX loan_release_recovery_events_recovery_id_idx ON loan_release_recovery_events(recovery_id);
CREATE TABLE loan_release_material_revisions (
payment_batch_id UUID PRIMARY KEY REFERENCES payment_batches(id) ON DELETE RESTRICT,
revision BIGINT NOT NULL DEFAULT 0 CHECK(revision>=0)
);
CREATE FUNCTION guard_loan_release_recovery_history() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE b payment_batches; r loan_repayments;
BEGIN
IF TG_OP='DELETE' OR TG_TABLE_NAME='loan_release_recovery_events' THEN
RAISE EXCEPTION 'recovery history is immutable' USING ERRCODE='23514';
END IF;
IF TG_OP='INSERT' THEN
SELECT * INTO b FROM payment_batches WHERE id=NEW.payment_batch_id FOR UPDATE;
SELECT * INTO r FROM loan_repayments WHERE id=b.loan_repayment_id;
IF b.entity_type<>'loan_repayment' OR b.status NOT IN ('reviewed','failed') OR
NEW.original_state IS DISTINCT FROM r.bank_result_state OR NEW.original_ref IS DISTINCT FROM r.bank_result_ref OR
NEW.status<>'collecting' OR NEW.snapshot_json IS NOT NULL OR NEW.reviewed_by IS NOT NULL OR
NEW.submitted_by IS NOT NULL OR NEW.submitted_at IS NOT NULL OR NEW.reviewed_at IS NOT NULL OR NEW.review_note IS NOT NULL OR
NEW.ended_at IS NOT NULL OR NEW.cancel_request_key IS NOT NULL OR NEW.consumed_by IS NOT NULL OR
NOT EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id) OR
EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=r.id AND reservation_state<>'reserved') THEN
RAISE EXCEPTION 'invalid recovery source' USING ERRCODE='23514';
END IF;
IF NEW.sequence <> COALESCE((SELECT max(sequence)+1 FROM loan_release_recoveries WHERE payment_batch_id=b.id),1) OR
NEW.previous_id IS DISTINCT FROM (SELECT id FROM loan_release_recoveries WHERE payment_batch_id=b.id ORDER BY sequence DESC LIMIT 1) THEN
RAISE EXCEPTION 'invalid recovery predecessor' USING ERRCODE='23514';
END IF;
RETURN NEW;
END IF;
IF (to_jsonb(NEW)-ARRAY['status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by'])
IS DISTINCT FROM (to_jsonb(OLD)-ARRAY['status','submitted_by','submitted_at','snapshot_json','reviewed_by','reviewed_at','review_note','ended_at','cancel_request_key','consumed_by']) OR
NOT ((OLD.status='collecting' AND NEW.status IN ('proposed','withdrawn')) OR
(OLD.status='proposed' AND NEW.status IN ('approved','rejected','withdrawn','superseded')) OR
(OLD.status='approved' AND NEW.status IN ('superseded','consumed'))) THEN
RAISE EXCEPTION 'invalid recovery transition' USING ERRCODE='23514';
END IF;
IF NOT (OLD.status='collecting' AND NEW.status='proposed') AND
(NEW.submitted_by,NEW.submitted_at,NEW.snapshot_json) IS DISTINCT FROM (OLD.submitted_by,OLD.submitted_at,OLD.snapshot_json) THEN
RAISE EXCEPTION 'immutable recovery submission' USING ERRCODE='23514';
END IF;
IF NOT (OLD.status='proposed' AND NEW.status IN ('approved','rejected')) AND
(NEW.reviewed_by,NEW.reviewed_at,NEW.review_note) IS DISTINCT FROM (OLD.reviewed_by,OLD.reviewed_at,OLD.review_note) THEN
RAISE EXCEPTION 'immutable recovery decision' USING ERRCODE='23514';
END IF;
IF NEW.status<>'consumed' AND (NEW.cancel_request_key,NEW.consumed_by) IS DISTINCT FROM (OLD.cancel_request_key,OLD.consumed_by) THEN
RAISE EXCEPTION 'invalid recovery consumption' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_release_recovery_history BEFORE INSERT OR UPDATE OR DELETE ON loan_release_recoveries FOR EACH ROW EXECUTE FUNCTION guard_loan_release_recovery_history();
CREATE TRIGGER loan_release_recovery_event_history BEFORE UPDATE OR DELETE ON loan_release_recovery_events FOR EACH ROW EXECUTE FUNCTION guard_loan_release_recovery_history();
-- All receipt/terminal/list writers share the parent lock and monotonically
-- advance material revisions, including background OCR and void/reversal ABA.
CREATE FUNCTION touch_loan_release_material() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE bid UUID; b payment_batches; lender UUID; data JSONB;
BEGIN
data := CASE WHEN TG_OP='DELETE' THEN to_jsonb(OLD) ELSE to_jsonb(NEW) END;
bid := (data->>'payment_batch_id')::uuid;
IF bid IS NULL THEN
SELECT payment_batch_id INTO bid FROM payment_batch_receipt_attachments WHERE id=(data->>'attachment_id')::uuid;
END IF;
SELECT * INTO b FROM payment_batches WHERE id=bid FOR UPDATE;
IF b.entity_type='loan_repayment' THEN
IF EXISTS(SELECT 1 FROM loan_release_recoveries WHERE payment_batch_id=bid) AND b.status IN ('cancelled','executed') THEN
RAISE EXCEPTION 'closed recovery evidence is immutable' USING ERRCODE='23514';
END IF;
IF TG_OP='INSERT' AND TG_TABLE_NAME IN ('payment_order_exports','loan_bank_submissions','payment_batch_receipt_attachments') THEN
SELECT lender_id INTO lender FROM loan_repayments WHERE id=b.loan_repayment_id;
PERFORM id FROM loan_lenders WHERE id=lender FOR UPDATE;
END IF;
INSERT INTO loan_release_material_revisions(payment_batch_id,revision) VALUES(bid,1)
ON CONFLICT(payment_batch_id) DO UPDATE SET revision=loan_release_material_revisions.revision+1;
END IF;
IF TG_OP='DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_order_exports FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_submissions FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_submission_attestations FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_terminal_evidences FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_bank_terminal_facts FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON loan_receipt_attributions FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_batch_receipt_attachments FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_receipt_verifications FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
CREATE TRIGGER loan_release_material_touch BEFORE INSERT OR UPDATE OR DELETE ON payment_receipt_manual_proposals FOR EACH ROW EXECUTE FUNCTION touch_loan_release_material();
-- Deferred: consumption, allocations and batch cancellation are one commit.
CREATE FUNCTION check_loan_recovery_release() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE bid UUID; latest loan_release_recoveries; b payment_batches; rid UUID;
BEGIN
IF TG_TABLE_NAME='loan_release_recoveries' THEN bid:=NEW.payment_batch_id;
ELSE SELECT id INTO bid FROM payment_batches WHERE loan_repayment_id=NEW.repayment_id; END IF;
SELECT * INTO latest FROM loan_release_recoveries WHERE payment_batch_id=bid ORDER BY sequence DESC LIMIT 1;
IF latest.id IS NULL THEN RETURN NEW; END IF;
SELECT * INTO b FROM payment_batches WHERE id=bid;
rid:=b.loan_repayment_id;
IF EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state='released') AND
(latest.status<>'consumed' OR b.status<>'cancelled') THEN
RAISE EXCEPTION 'release requires latest recovery consumption' USING ERRCODE='23514';
END IF;
IF latest.status='consumed' AND (b.status<>'cancelled' OR EXISTS(SELECT 1 FROM loan_repayment_allocations WHERE repayment_id=rid AND reservation_state<>'released')) THEN
RAISE EXCEPTION 'recovery consumption requires atomic cancellation' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE CONSTRAINT TRIGGER loan_recovery_release_check AFTER INSERT OR UPDATE ON loan_release_recoveries DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_recovery_release();
CREATE CONSTRAINT TRIGGER loan_recovery_allocation_check AFTER UPDATE ON loan_repayment_allocations DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION check_loan_recovery_release();
CREATE FUNCTION guard_loan_material_revision() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' OR NEW.payment_batch_id IS DISTINCT FROM OLD.payment_batch_id OR NEW.revision<>OLD.revision+1 THEN
RAISE EXCEPTION 'material revision must increase once' USING ERRCODE='23514';
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER loan_material_revision_history BEFORE UPDATE OR DELETE ON loan_release_material_revisions FOR EACH ROW EXECUTE FUNCTION guard_loan_material_revision();
+64
查看文件
@@ -745,6 +745,12 @@ model Employee {
loan_terminal_facts_proposed LoanBankTerminalFact[] @relation("LoanTerminalFactProposer")
loan_terminal_facts_reviewed LoanBankTerminalFact[] @relation("LoanTerminalFactReviewer")
@@map("employees")
loan_recoveries_started LoanReleaseRecovery[] @relation("LoanReleaseStarter")
loan_recoveries_submitted LoanReleaseRecovery[] @relation("LoanReleaseSubmitter")
loan_recoveries_reviewed LoanReleaseRecovery[] @relation("LoanReleaseReviewer")
loan_recoveries_consumed LoanReleaseRecovery[] @relation("LoanReleaseConsumer")
loan_recovery_events LoanReleaseRecoveryEvent[]
}
model PasswordResetCode {
@@ -1992,6 +1998,9 @@ model PaymentBatch {
loan_terminal_facts LoanBankTerminalFact[]
loan_failure_evidence_uses LoanFailureEvidenceUse[]
@@map("payment_batches")
loan_release_recoveries LoanReleaseRecovery[]
loan_material_revision LoanReleaseMaterialRevision?
}
model PaymentBatchItem {
@@ -4180,3 +4189,58 @@ model LoanFailureEvidenceUse {
@@map("loan_failure_evidence_uses")
}
// SR: original bank conclusions remain immutable; recovery controls release only.
model LoanReleaseRecovery {
id String @id @default(uuid()) @db.Uuid
payment_batch_id String @db.Uuid
sequence Int
previous_id String? @db.Uuid
request_key String @db.Uuid
original_state String @db.VarChar(32)
original_ref String
reason String @db.VarChar(1000)
started_by String @db.Uuid
started_at DateTime @default(now())
status String @default("collecting") @db.VarChar(16)
submitted_by String? @db.Uuid
submitted_at DateTime?
snapshot_json Json?
reviewed_by String? @db.Uuid
reviewed_at DateTime?
review_note String? @db.VarChar(500)
ended_at DateTime?
cancel_request_key String? @db.Uuid
consumed_by String? @db.Uuid
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
starter Employee @relation("LoanReleaseStarter", fields: [started_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
submitter Employee? @relation("LoanReleaseSubmitter", fields: [submitted_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
reviewer Employee? @relation("LoanReleaseReviewer", fields: [reviewed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
consumer Employee? @relation("LoanReleaseConsumer", fields: [consumed_by], references: [id], onDelete: Restrict, onUpdate: NoAction)
previous LoanReleaseRecovery? @relation("LoanRecoveryPrevious", fields: [previous_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
next LoanReleaseRecovery[] @relation("LoanRecoveryPrevious")
events LoanReleaseRecoveryEvent[]
@@unique([payment_batch_id, sequence])
@@unique([payment_batch_id, request_key])
@@map("loan_release_recoveries")
}
model LoanReleaseRecoveryEvent {
id String @id @default(uuid()) @db.Uuid
recovery_id String @db.Uuid
action String @db.VarChar(32)
actor_id String @db.Uuid
note String @db.VarChar(1000)
created_at DateTime @default(now())
recovery LoanReleaseRecovery @relation(fields: [recovery_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
actor Employee @relation(fields: [actor_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@index([recovery_id])
@@map("loan_release_recovery_events")
}
model LoanReleaseMaterialRevision {
payment_batch_id String @id @db.Uuid
revision BigInt @default(0)
payment_batch PaymentBatch @relation(fields: [payment_batch_id], references: [id], onDelete: Restrict, onUpdate: NoAction)
@@map("loan_release_material_revisions")
}
+5
查看文件
@@ -1,4 +1,9 @@
const ERROR_REASONS = [
{"code":"loan.recovery_required","domain":"loan","audience":"user","defaultMessage":"释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。","recovery":"follow_guidance"},
{"code":"loan.recovery_unavailable","domain":"loan","audience":"user","defaultMessage":"当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。","recovery":"follow_guidance"},
{"code":"loan.recovery_exists","domain":"loan","audience":"user","defaultMessage":"已有补证中或有效的恢复复核,请继续处理当前轮次。","recovery":"follow_guidance"},
{"code":"loan.recovery_stale","domain":"loan","audience":"user","defaultMessage":"补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。","recovery":"follow_guidance"},
{"code":"integration.erp_retired","domain":"integration","audience":"user","defaultMessage":"ERP 集成已停用,历史记录仅供查询,不能重新投递。","recovery":"follow_guidance"},
{"code":"loan.invalid_input","domain":"loan","audience":"user","defaultMessage":"借款信息不符合要求,请检查必填项、金额和日期。","recovery":"follow_guidance"},
{"code":"loan.not_found","domain":"loan","audience":"user","defaultMessage":"借款记录不存在或已不可用,请刷新后重新选择。","recovery":"follow_guidance"},
+42 -3
查看文件
@@ -6,6 +6,7 @@ const { REPAYMENT_INCLUDE, readLoanRepayment, validateLoanPayment, prepareLoanRe
proposeReceiptAttribution, reviewReceiptAttribution, loanReceiptAttributionState, loanAttributionSatisfied } = require("../services/companyLoanPayments");
const { recordLoanPaymentOrderExport, registerBankSubmission, withdrawBankSubmission, proposeSubmissionAttestation,
reviewSubmissionAttestation, bankSubmissionState } = require("../services/loanBankSubmissions");
const releaseRecoveries = require("../services/loanReleaseRecoveries");
const { uploadTerminalEvidence, voidTerminalEvidence, proposeTerminalFact, reviewTerminalFact, confirmNotPaid,
terminalState } = require("../services/loanBankTerminalFacts");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
@@ -1053,7 +1054,7 @@ async function mapBatchForResponse(tx, batch, openRiskFlags, config, options = {
...(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),
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch) };
bankSubmissions: await bankSubmissionState(tx, batch), terminal: await terminalState(tx, batch), releaseRecovery: await releaseRecoveries.recoveryState(tx, batch) };
})() } : {}),
...mapBatch(batch, openRiskFlags, legacyPayees, {
includePayee: true,
@@ -1511,6 +1512,8 @@ async function paymentBatchesRoutes(fastify) {
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)`;
const repayment = await readLoanRepayment(tx, batch);
await require("../services/companyLoans").lockLender(tx, repayment.lender_id);
return await work(tx, batch); } catch (error) { mapManualReceiptError(error); }
});
}
@@ -1552,6 +1555,37 @@ async function paymentBatchesRoutes(fastify) {
const result = await attributionTransaction(request, (tx, batch) => reviewSubmissionAttestation(tx, batch, request, attestationId, body));
return sendData(reply, request, { id: result.id, status: result.status });
});
const RECOVERY_START_SCHEMA = z.object({ requestKey: UUID_SCHEMA, reason: z.string().trim().min(5).max(1000) }).strict();
const RECOVERY_REASON_SCHEMA = z.object({ reason: z.string().trim().min(2).max(500) }).strict();
const recoveryResponse = (r) => ({ id: r.id, sequence: r.sequence, status: r.status });
fastify.post("/payment-batches/:id/loan-release-recoveries", { preHandler: authenticateBatch }, async (request, reply) => {
const input = parseBody(RECOVERY_START_SCHEMA, request.body);
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.startRecovery(tx, fastify.config, batch, request, input));
return sendData(reply, request, recoveryResponse(result));
});
fastify.get("/payment-batches/:id/loan-release-recoveries", { preHandler: authenticateBatch }, async (request, reply) => {
const batch = await loadBatchWithItems(fastify.prisma, parseId(request.params.id));
if (batch.entity_type !== "loan_repayment") throw buildBusinessValidationError("loan.recovery_unavailable", {}, 409);
return sendData(reply, request, await releaseRecoveries.recoveryState(fastify.prisma, batch));
});
fastify.post("/payment-batches/:id/loan-release-recoveries/:recoveryId/submit", { preHandler: authenticateBatch }, async (request, reply) => {
parseBody(z.object({}).strict(), request.body ?? {});
const id = parseId(request.params.recoveryId);
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.submitRecovery(tx, fastify.config, batch, request, id));
return sendData(reply, request, recoveryResponse(result));
});
fastify.post("/payment-batches/:id/loan-release-recoveries/:recoveryId/review", { preHandler: authenticateBatch }, async (request, reply) => {
const input = parseBody(ATTRIBUTION_REVIEW_SCHEMA, request.body);
const id = parseId(request.params.recoveryId);
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.reviewRecovery(tx, fastify.config, batch, request, id, input));
return sendData(reply, request, recoveryResponse(result));
});
fastify.post("/payment-batches/:id/loan-release-recoveries/:recoveryId/withdraw", { preHandler: authenticateBatch }, async (request, reply) => {
const { reason } = parseBody(RECOVERY_REASON_SCHEMA, request.body);
const id = parseId(request.params.recoveryId);
const result = await attributionTransaction(request, (tx, batch) => releaseRecoveries.withdrawRecovery(tx, batch, request, id, reason));
return sendData(reply, request, recoveryResponse(result));
});
fastify.post("/payment-batches/:id/loan-terminal-evidences", { preHandler: authenticateBatch }, async (request, reply) => {
const files = [];
try {
@@ -2956,9 +2990,14 @@ async function paymentBatchesRoutes(fastify) {
await ensureFeatureEnabled(fastify);
ensureBatchManagePermission(fastify, request);
const batchId = parseId(request.params?.id);
const { reason } = parseBody(REASON_SCHEMA, request.body);
const input = parseBody(REASON_SCHEMA.extend({ recoveryId: UUID_SCHEMA.optional(), requestKey: UUID_SCHEMA.optional() }), request.body);
const { reason } = input;
const cancelled = await fastify.prisma.$transaction(async (tx) => {
const batch = await lockBatchWithItems(tx, batchId);
if (batch.entity_type === "loan_repayment") {
await tx.$queryRaw`SELECT 1 AS locked FROM pg_advisory_xact_lock(${ADVISORY_LOCK_NAMESPACES.paymentReceiptClaims}::int, 1)`;
if (await releaseRecoveries.isRecoveryCancelRetry(tx, batch, request, input)) return batch;
}
await ensureBatchSourceDepartmentsEnabled(fastify, tx, {
...batch,
items: batch.items.filter((item) => ["pending", "failed"].includes(item.status)),
@@ -2966,7 +3005,7 @@ async function paymentBatchesRoutes(fastify) {
if (["executed", "cancelled"].includes(batch.status)) {
throw buildBusinessValidationError("payment_batch.batch_not_cancellable", { status: "batch_not_cancellable" }, 409);
}
if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason, fastify.config);
if (batch.entity_type === "loan_repayment") await cancelLoanRepayment(tx, batch, request, reason, fastify.config, input);
const openItemIds = batch.items
.filter((item) => ["pending", "failed"].includes(item.status))
.map((item) => item.id);
+6 -4
查看文件
@@ -236,7 +236,7 @@ async function settleLoanRepayment(tx, batch, request, config) {
await auditLoan(tx, request, "LOAN_PRINCIPAL_SETTLED", repayment.id, { paymentBatchId: batch.id, allocationCount: repayment.allocations.length });
}
async function cancelLoanRepayment(tx, batch, request, reason, config) {
async function cancelLoanRepayment(tx, batch, request, reason, config, recoveryInput = {}) {
// Stale identity/account must not prevent cancelling a never-sent request.
const repayment = await readLoanRepayment(tx, batch);
if (!["not_sent", "confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) ||
@@ -250,9 +250,10 @@ async function cancelLoanRepayment(tx, batch, request, reason, config) {
await tx.loanBankTerminalEvidence.count({ where: { payment_batch_id: batch.id } }))) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
// Release re-validates the conclusion inside this transaction (batch lock held
// by the caller serializes it with new submissions and receipt uploads).
if (repayment.bank_result_state === "confirmed_not_paid") {
const recovered = await require("./loanReleaseRecoveries").consumeRecovery(tx, config, batch, request, recoveryInput);
if (!recovered && repayment.bank_result_state === "confirmed_not_paid") {
await require("./loanBankTerminalFacts").assertNotPaidReleasable(tx, config, batch);
} else if (repayment.bank_result_state === "confirmed_failed") {
} else if (!recovered && repayment.bank_result_state === "confirmed_failed") {
const verificationId = String(repayment.bank_result_ref ?? "").replace(/^verification:/, "");
const verification = verificationId && await tx.paymentReceiptVerification.findUnique({ where: { id: verificationId } });
if (!verification) { throw buildBusinessValidationError("loan.bank_state_unsafe", {}, 409); };
@@ -409,7 +410,8 @@ async function plannedLoanClaim(tx, config, batch) {
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); };
if (repayment.allocations.some((a) => a.reservation_state !== "reserved")) { throw buildBusinessValidationError("loan.payment_not_ready", {}, 409); };
if (repayment.bank_result_state === "confirmed_failed") await require("./loanReleaseRecoveries").assertRecoveryCollecting(tx, batch);
const competitors = await competingLoanBatches(tx, batch, repayment);
if (requireCompetitors && !competitors.length) { throw buildBusinessValidationError("loan.receipt_attribution_not_required", {}, 409); };
return { repayment, competitors };
+1 -1
查看文件
@@ -95,7 +95,7 @@ async function proposeTerminalFact(tx, config, batch, request, input) {
const { readLoanRepayment } = loanPayments();
const repayment = await readLoanRepayment(tx, batch);
assertTerminalBatch(batch, repayment);
if (repayment.bank_result_state !== "unknown") throw buildBusinessValidationError("loan.payment_not_ready", {}, 409);
if (repayment.bank_result_state !== "unknown") await require("./loanReleaseRecoveries").assertRecoveryCollecting(tx, batch);
if (!TERMINAL_KINDS.includes(input.kind)) throw buildBusinessValidationError("loan.invalid_input", {}, 400);
await supersedeStaleTerminalFacts(tx, config, batch, request);
const submission = await tx.loanBankSubmission.findUnique({ where: { id: input.submissionId } });
@@ -0,0 +1,209 @@
const { createHash } = require("node:crypto");
const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { getErrorReason } = require("../constants/errorReasons");
const { auditLoan } = require("./companyLoans");
const payments = () => require("./companyLoanPayments");
const terminal = () => require("./loanBankTerminalFacts");
const fail = (code) => { throw buildBusinessValidationError(getErrorReason(code).code, {}, 409); };
const OPEN = ["collecting", "proposed", "approved"];
// JSONB does not preserve object key order. Canonicalize keys both before
// persistence and after reading; array order is deliberately significant.
const digest = (value) => createHash("sha256").update(JSON.stringify(value, (_key, item) => {
if (typeof item === "bigint") return String(item);
return item && typeof item === "object" && !Array.isArray(item)
? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item;
})).digest("hex");
async function latestRecovery(tx, batchId) {
return tx.loanReleaseRecovery.findFirst({ where: { payment_batch_id: batchId }, orderBy: { sequence: "desc" } });
}
async function recoverable(tx, batch) {
const repayment = await payments().readLoanRepayment(tx, batch);
if (!["reviewed", "failed"].includes(batch.status) ||
!["confirmed_failed", "confirmed_not_paid"].includes(repayment.bank_result_state) ||
!repayment.allocations.length || repayment.allocations.some((a) => a.reservation_state !== "reserved")) fail("loan.recovery_unavailable");
return repayment;
}
// Only the current collecting round opens terminal-fact entry. It never opens
// export, payment execution, or the historical bank-result fields.
async function assertRecoveryCollecting(tx, batch) {
await recoverable(tx, batch);
const latest = await latestRecovery(tx, batch.id);
if (latest?.status !== "collecting") fail("loan.recovery_required");
return latest;
}
async function participants(tx, batch, repayment) {
const rounds = await tx.loanReleaseRecovery.findMany({ where: { payment_batch_id: batch.id } });
const manual = await tx.paymentReceiptManualProposal.findMany({ where: { attachment: { payment_batch_id: batch.id } }, select: { proposed_by: true } });
return [...new Set([...(await terminal().terminalParticipants(tx, batch, repayment)),
...rounds.flatMap((r) => [r.started_by, r.submitted_by]), ...manual.map((m) => m.proposed_by)].filter(Boolean))].sort();
}
// Stored snapshot contains identifiers/digests, never decrypted bank data.
// The database revision covers every material writer (including OCR workers).
// Candidates are re-read under the lender lock, even when another batch changed.
async function snapshot(tx, batch) {
const repayment = await payments().readLoanRepayment(tx, batch);
const [revision, exports, submissions, lists, facts, evidences, receipts, attributions, competitors] = await Promise.all([
tx.loanReleaseMaterialRevision.findUnique({ where: { payment_batch_id: batch.id } }),
tx.paymentOrderExport.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.loanBankSubmission.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.loanBankSubmissionAttestation.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.loanBankTerminalEvidence.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" },
include: { verifications: { orderBy: { id: "asc" } }, manual_proposals: { orderBy: { id: "asc" } } } }),
tx.loanReceiptAttribution.findMany({ where: { payment_batch_id: batch.id }, orderBy: { id: "asc" } }),
payments().competingLoanBatches(tx, batch, repayment),
]);
const summarize = (rows) => rows.map((row) => ({ id: row.id, digest: digest(row) }));
return { revision: String(revision?.revision ?? 0), originalState: repayment.bank_result_state,
originalRef: repayment.bank_result_ref, payment: digest(batch.items.map((i) => ({ id: i.id, snapshot: i.snapshot_json, amount: i.amount }))),
allocations: repayment.allocations.map((a) => ({ id: a.id, loanId: a.loan_id, amount: String(a.amount), state: a.reservation_state })),
exports: summarize(exports), submissions: summarize(submissions), lists: summarize(lists), facts: summarize(facts),
evidences: summarize(evidences), receipts: summarize(receipts), attributions: summarize(attributions),
competitors, participants: await participants(tx, batch, repayment) };
}
async function unchanged(tx, batch, round) {
return Boolean(round.snapshot_json) && digest(round.snapshot_json) === digest(await snapshot(tx, batch));
}
async function event(tx, request, batch, round, action, note) {
await tx.loanReleaseRecoveryEvent.create({ data: { recovery_id: round.id, action, actor_id: request.employee.id, note } });
await auditLoan(tx, request, `LOAN_RELEASE_RECOVERY_${action.toUpperCase()}`, batch.loan_repayment_id,
{ paymentBatchId: batch.id, recoveryId: round.id });
}
async function startRecovery(tx, config, batch, request, input) {
const existing = await tx.loanReleaseRecovery.findUnique({ where: { payment_batch_id_request_key: { payment_batch_id: batch.id, request_key: input.requestKey } } });
if (existing) {
if (existing.started_by !== request.employee.id || existing.reason !== input.reason) fail("loan.duplicate_registration");
return existing;
}
const repayment = await recoverable(tx, batch);
const previous = await latestRecovery(tx, batch.id);
if (previous && OPEN.includes(previous.status)) {
if (previous.status === "collecting" || await unchanged(tx, batch, previous)) fail("loan.recovery_exists");
await tx.loanReleaseRecovery.update({ where: { id: previous.id }, data: { status: "superseded", ended_at: new Date() } });
await event(tx, request, batch, previous, "superseded", "材料变化,另建恢复轮次");
}
const round = await tx.loanReleaseRecovery.create({ data: { payment_batch_id: batch.id, sequence: (previous?.sequence ?? 0) + 1,
previous_id: previous?.id ?? null, original_state: repayment.bank_result_state, original_ref: repayment.bank_result_ref,
request_key: input.requestKey, reason: input.reason, started_by: request.employee.id } });
await event(tx, request, batch, round, "started", input.reason);
return round;
}
async function currentRound(tx, batch, id) {
const round = await latestRecovery(tx, batch.id);
if (!round || round.id !== id) fail("loan.recovery_unavailable");
return round;
}
async function assertReleaseEvidence(tx, config, batch) {
await payments().validateLoanPayment(tx, batch, { lock: true, postPayment: true });
await recoverable(tx, batch);
await terminal().assertNotPaidReleasable(tx, config, batch);
// Read/hash all currently relied-on physical documents, not merely metadata.
const facts = await tx.loanBankTerminalFact.findMany({ where: { payment_batch_id: batch.id, status: "approved" } });
const evidenceIds = facts.map((f) => f.evidence_id).filter(Boolean);
const evidence = await tx.loanBankTerminalEvidence.findMany({ where: { id: { in: evidenceIds } } });
const receipts = await tx.paymentBatchReceiptAttachment.findMany({ where: { payment_batch_id: batch.id },
include: { verifications: true, manual_proposals: true } });
const { decryptStringOrThrow } = require("./crypto");
const { batchCandidates } = require("./paymentReceiptVerification");
const { matchBankSlip } = require("./paymentReceiptMatching");
for (const receipt of receipts) {
if (receipt.manual_proposals.some((p) => p.status !== "rejected")) fail("loan.receipt_unresolved_for_release");
for (const verification of receipt.verifications) {
const recognition = JSON.parse(decryptStringOrThrow(config, verification.normalized_encrypted));
if (verification.status === "matched" || (recognition.pages ?? []).some((page) => page.transactionStatus === "success" &&
matchBankSlip(page, batchCandidates(config, batch)).status === "matched")) fail("loan.receipt_unresolved_for_release");
}
}
const { openReadStream } = require("./storage");
for (const file of [...evidence, ...receipts]) {
try {
const opened = await openReadStream({ config, storagePath: file.storage_path });
const hash = createHash("sha256"); let bytes = 0;
for await (const chunk of opened.stream) { bytes += chunk.length; hash.update(chunk); }
if (bytes !== file.file_size || hash.digest("hex") !== file.file_sha256) fail("loan.terminal_evidence_invalid");
} catch { fail("loan.terminal_evidence_invalid"); }
}
}
async function submitRecovery(tx, config, batch, request, id) {
const round = await currentRound(tx, batch, id);
if (round.status !== "collecting") fail("loan.recovery_unavailable");
if (round.started_by !== request.employee.id) throw buildBusinessValidationError("loan.independent_review_required", {}, 403);
await assertReleaseEvidence(tx, config, batch);
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: "proposed", submitted_by: request.employee.id,
submitted_at: new Date(), snapshot_json: await snapshot(tx, batch) } });
await event(tx, request, batch, updated, "submitted", "补证完整,提交独立复核");
return updated;
}
async function reviewRecovery(tx, config, batch, request, id, input) {
const round = await currentRound(tx, batch, id);
if (round.status !== "proposed") fail("loan.recovery_unavailable");
const repayment = await recoverable(tx, batch);
await payments().activeReviewer(tx, request.employee.id, await participants(tx, batch, repayment), { lock: true });
if (input.decision === "approve") {
await assertReleaseEvidence(tx, config, batch);
if (!await unchanged(tx, batch, round)) fail("loan.recovery_stale");
}
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: input.decision === "approve" ? "approved" : "rejected",
reviewed_by: request.employee.id, reviewed_at: new Date(), review_note: input.reason,
...(input.decision === "reject" ? { ended_at: new Date() } : {}) } });
await event(tx, request, batch, updated, updated.status, input.reason);
return updated;
}
async function withdrawRecovery(tx, batch, request, id, reason) {
const round = await currentRound(tx, batch, id);
await recoverable(tx, batch);
if (!["collecting", "proposed"].includes(round.status)) fail("loan.recovery_unavailable");
if (round.started_by !== request.employee.id) throw buildBusinessValidationError("loan.independent_review_required", {}, 403);
const updated = await tx.loanReleaseRecovery.update({ where: { id }, data: { status: "withdrawn", ended_at: new Date() } });
await event(tx, request, batch, updated, "withdrawn", reason);
return updated;
}
// Called inside the existing cancel transaction, before allocations change.
// Once any round exists there is NO fallback to the original bank conclusion.
async function consumeRecovery(tx, config, batch, request, input) {
const round = await latestRecovery(tx, batch.id);
if (!round) {
if (input.recoveryId || input.requestKey) fail("loan.recovery_unavailable");
return false;
}
if (round.id !== input.recoveryId || round.status !== "approved" || !input.requestKey) fail("loan.recovery_required");
await assertReleaseEvidence(tx, config, batch);
if (!await unchanged(tx, batch, round)) fail("loan.recovery_stale");
const updated = await tx.loanReleaseRecovery.update({ where: { id: round.id }, data: { status: "consumed", ended_at: new Date(),
cancel_request_key: input.requestKey, consumed_by: request.employee.id } });
await event(tx, request, batch, updated, "consumed", "复核依据重验通过,原子取消释放");
return true;
}
async function isRecoveryCancelRetry(tx, batch, request, input) {
if (batch.status !== "cancelled" || !input.recoveryId || !input.requestKey) return false;
const round = await latestRecovery(tx, batch.id);
return round?.status === "consumed" && round.id === input.recoveryId && round.cancel_request_key === input.requestKey && round.consumed_by === request.employee.id;
}
async function recoveryState(tx, batch) {
const rows = await tx.loanReleaseRecovery.findMany({ where: { payment_batch_id: batch.id }, orderBy: { sequence: "desc" } });
const current = rows[0];
const stale = current && ["proposed", "approved"].includes(current.status) && !await unchanged(tx, batch, current);
return { current: current ? { id: current.id, sequence: current.sequence, status: current.status, stale: Boolean(stale) } : null,
history: rows.map((r) => ({ id: r.id, sequence: r.sequence, status: r.status, reason: r.reason, startedBy: r.started_by,
submittedBy: r.submitted_by, reviewedBy: r.reviewed_by, reviewNote: r.review_note, originalState: r.original_state })) };
}
module.exports = { startRecovery, submitRecovery, reviewRecovery, withdrawRecovery, consumeRecovery,
isRecoveryCancelRetry, recoveryState, assertRecoveryCollecting, latestRecovery };
@@ -1,4 +1,9 @@
{
"loan.recovery_required": "释放依据已进入补证流程,须完成最新一轮独立复核后才能取消释放。",
"loan.recovery_unavailable": "当前批次或恢复轮次不支持此操作;已释放、已核销的还款不能重新开启。",
"loan.recovery_exists": "已有补证中或有效的恢复复核,请继续处理当前轮次。",
"loan.recovery_stale": "补证材料、提交清单或候选批次已变化,请重新发起恢复并独立复核。",
"integration.erp_retired": "ERP 集成已停用,历史记录仅供查询,不能重新投递。",
"loan.invalid_input": "借款信息不符合要求,请检查必填项、金额和日期。",
"loan.not_found": "借款记录不存在或已不可用,请刷新后重新选择。",