文件
hyb-oa/server/src/services/loanFreezes.js
T
wangsli 431c36eae3 借款对账 S4–S8:人工未提交、晚到补记、追回与界面收口(09-17-company-loan-principal-repayment)
新增三类证据与双人复核的人工未提交、逐原因冻结、晚到付款逐借款补记和实际退款追回;补齐对账与补证恢复界面,保护已消费依据、最终归属重验及全局交易唯一性。

验证:81 迁移、HTTP 95 组、浏览器 13 组、数据库约束 9 组;13 项有效变异被捕获,部分由下游或数据库保护检出。构建、类型、合同和治理检查通过;修复后同模型只读静态复核通过。

真实回单待验、整体业务验收未通过;S0 存量、人员、银行材料与业务独立验收未完成。3 个新增迁移须纳入获批发布清单。本次仅本地提交,不推送、不部署。
2026-10-07 21:58:03 +08:00

46 行
2.8 KiB
JavaScript

const { buildBusinessValidationError } = require("../utils/businessValidationError");
const { lockLender, auditLoan } = require("./companyLoans");
// Call under the lender lock for all pre-payment actions. Bank facts never use
// this gate. A closed cause cannot mask another active cause.
async function assertNotFrozen(tx, lenderId) {
if (await tx.loanFreezeReason.count({ where: { lender_id: lenderId, status: "active" } })) {
throw buildBusinessValidationError("loan.lender_frozen", {}, 409);
}
}
async function openReason(tx, batch, request, kind, sourceKey) {
const repayment = await require("./companyLoanPayments").readLoanRepayment(tx, batch);
await lockLender(tx, repayment.lender_id);
const existing = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind, source_key: sourceKey } } });
if (existing) {
if (existing.payment_batch_id !== batch.id) throw buildBusinessValidationError("loan.invalid_input", {}, 409);
return existing;
}
const reason = await tx.loanFreezeReason.create({ data: { lender_id: repayment.lender_id, payment_batch_id: batch.id,
kind, source_key: sourceKey, created_by: request.employee.id } });
await auditLoan(tx, request, "LOAN_FREEZE_OPENED", repayment.id, { paymentBatchId: batch.id, freezeId: reason.id, kind });
return reason;
}
// Internal only: S6/S7 call after resolving their source fact in this same
// transaction. Never expose a generic close/unfreeze route to users.
async function closeReason(tx, batch, request, kind, sourceKey, basis) {
const repayment = await require("./companyLoanPayments").readLoanRepayment(tx, batch);
await lockLender(tx, repayment.lender_id);
const reason = await tx.loanFreezeReason.findUnique({ where: { kind_source_key: { kind, source_key: sourceKey } } });
if (!reason || reason.payment_batch_id !== batch.id || !basis?.action || !basis?.recordId) {
throw buildBusinessValidationError("loan.invalid_input", {}, 409);
}
if (reason.status === "closed") return reason;
const closed = await tx.loanFreezeReason.update({ where: { id: reason.id }, data: { status: "closed",
closed_by: request.employee.id, closed_at: new Date(), closure_basis: basis } });
await auditLoan(tx, request, "LOAN_FREEZE_CLOSED", repayment.id, { paymentBatchId: batch.id, freezeId: reason.id, kind, basis });
return closed;
}
async function state(tx, lenderId) {
const reasons = await tx.loanFreezeReason.findMany({ where: { lender_id: lenderId }, orderBy: [{ created_at: "asc" }, { id: "asc" }] });
return { frozen: reasons.some((r) => r.status === "active"), reasons: reasons.map((r) => ({ id: r.id, kind: r.kind,
sourceKey: r.source_key, paymentBatchId: r.payment_batch_id, status: r.status, closureBasis: r.closure_basis })) };
}
module.exports = { assertNotFrozen, openReason, closeReason, state };