借款对账 SR-A/B:补证恢复与复核后释放(09-17-company-loan-principal-repayment)
保留原银行终态及依据,以恢复轮次绑定材料、候选和独立性;最终取消重验并原子消费批准、释放占用和记录审计。新增增量迁移、HTTP 回归及数据库保护。 验证:78 个迁移、HTTP 72 组、既有浏览器 7 组、数据库约束 9 组通过;7 个变异均被抓住。服务端首跑 4470 通过、6 失败、1 跳过,补齐 BIFF8 测试依赖后相关 146 项通过,未重跑完整套件。 限制:inline 主会话自查不等于独立复核,独立复核待完成;界面未完成,不能单独部署;真实回单待验。新恢复界面留待 S8,S4 另批,整体借款功能尚未完成验收。本次仅本地提交,不推送、不部署、不查询生产。
这个提交包含在:
@@ -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"},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
在新工单中引用
屏蔽一个用户